diff --git a/.scrutinizer.yml b/.scrutinizer.yml new file mode 100644 index 0000000..e2cb043 --- /dev/null +++ b/.scrutinizer.yml @@ -0,0 +1,5 @@ +imports: + - php + +tools: + external_code_coverage: true diff --git a/.travis.yml b/.travis.yml index ad1f366..fbb22d1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,9 +1,36 @@ language: php +cache: + directories: + - $HOME/.composer/cache + php: - 5.3 - 5.4 + - 5.5 + - 5.6 + +matrix: + include: + - php: 5.5 + env: SYMFONY_VERSION='2.3.*' + - php: 5.5 + env: SYMFONY_VERSION='2.5.*' before_script: - - echo "extension = mongo.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini - - composer install --dev + - /usr/share/elasticsearch/bin/elasticsearch -v + - sudo /usr/share/elasticsearch/bin/plugin -install elasticsearch/elasticsearch-mapper-attachments/2.0.0 + - sudo service elasticsearch restart + - sh -c 'if [ "$TRAVIS_PHP_VERSION" != "hhvm" ]; then echo "extension = mongo.so" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini; fi;' + - sh -c 'if [ "$SYMFONY_VERSION" != "" ]; then composer require --dev --no-update symfony/symfony=$SYMFONY_VERSION; fi;' + - composer install --dev --prefer-source + +script: + - vendor/bin/phpunit --coverage-clover=coverage.clover + +services: + - elasticsearch + +after_script: + - wget https://scrutinizer-ci.com/ocular.phar + - php ocular.phar code-coverage:upload --format=php-clover coverage.clover diff --git a/Annotation/Search.php b/Annotation/Search.php new file mode 100644 index 0000000..26e1dbf --- /dev/null +++ b/Annotation/Search.php @@ -0,0 +1,16 @@ + + * @Annotation + * @Target("CLASS") + */ +class Search +{ + /** @var string */ + public $repositoryClass; +} diff --git a/CHANGELOG-2.0.md b/CHANGELOG-2.0.md index b384948..26c779c 100644 --- a/CHANGELOG-2.0.md +++ b/CHANGELOG-2.0.md @@ -12,6 +12,13 @@ https://github.com/FriendsOfSymfony/FOSElasticaBundle/compare/v2.0.0...v2.0.1 To generate a changelog summary since the last version, run `git log --no-merges --oneline v2.0.0...2.0.x` +* 2.0.2 (2013-06-06) + + * 00e9a49: Allow Symfony dependencies until 3.0 + * 4b4a56d: Check for "indexes" key in Configuration::getNestings() + * 8ffd1a7: Update install version and add links to compatibility info + * 58e983f: Document installation via composer in README (closes #271) + * 2.0.1 (2013-04-04) * f0d3a4d: Ensure mongo extension is available in Travis CI diff --git a/CHANGELOG-2.1.md b/CHANGELOG-2.1.md new file mode 100644 index 0000000..4069cdd --- /dev/null +++ b/CHANGELOG-2.1.md @@ -0,0 +1,22 @@ +CHANGELOG for 2.1.x +=================== + +This changelog references the relevant changes (bug and security fixes) done +in 2.1 minor versions. + +To get the diff for a specific change, go to +https://github.com/FriendsOfSymfony/FOSElasticaBundle/commit/XXX where XXX is +the commit hash. To get the diff between two versions, go to +https://github.com/FriendsOfSymfony/FOSElasticaBundle/compare/v2.1.0...v2.1.1 + +To generate a changelog summary since the last version, run +`git log --no-merges --oneline v2.1.0...2.1.x` + +* 2.1.2 (2013-06-06) + + * 00e9a49: Allow Symfony dependencies until 3.0 + +* 2.1.1 (2013-05-15) + + * c05e0ca: Added documentation for ignoring missing hits + * 00b67fd: Ignore missing index hits diff --git a/CHANGELOG-3.0.md b/CHANGELOG-3.0.md new file mode 100644 index 0000000..57ca45c --- /dev/null +++ b/CHANGELOG-3.0.md @@ -0,0 +1,76 @@ +CHANGELOG for 3.0.x +=================== + +This changelog references the relevant changes (bug and security fixes) done +in 3.0 minor versions. + +To get the diff for a specific change, go to +https://github.com/FriendsOfSymfony/FOSElasticaBundle/commit/XXX where XXX is +the commit hash. To get the diff between two versions, go to +https://github.com/FriendsOfSymfony/FOSElasticaBundle/compare/v3.0.0...v3.0.1 + +To generate a changelog summary since the last version, run +`git log --no-merges --oneline v3.0.0...3.0.x` + +* 3.0.9 (2015-03-12) + + * Fix a bug in the BC layer of the type configuration for empty configs + * Fix the service definition for the Doctrine listener when the logger is not enabled + +* 3.0.8 (2014-01-31) + + * Fixed handling of empty indexes #760 + * Added support for `connectionStrategy` Elastica configuration #732 + * Allow Elastica 1.4 + +* 3.0.7 (2015-01-21) + + * Fixed the indexing of parent/child relations, broken since 3.0 #774 + * Fixed multi_field properties not being normalised #769 + +* 3.0.6 (2015-01-04) + + * Removed unused public image asset for the web development toolbar #742 + * Fixed is_indexable_callback BC code to support array notation #761 + * Fixed debug_logger for type providers #724 + * Clean the OM if we filter away the entire batch #737 + +* 3.0.0-ALPHA6 + + * Moved `is_indexable_callback` from the listener properties to a type property called + `indexable_callback` which is run when both populating and listening for object + changes. + * AbstractProvider constructor change: Second argument is now an `IndexableInterface` + instance. + * Annotation @Search moved to FOS\ElasticaBundle\Annotation\Search with FOS\ElasticaBundle\Configuration\Search deprecated + * Deprecated FOS\ElasticaBundle\Client in favour of FOS\ElasticaBundle\Elastica\Client + * Deprecated FOS\ElasticaBundle\DynamicIndex in favour of FOS\ElasticaBundle\Elastica\Index + * Deprecated FOS\ElasticaBundle\IndexManager in favour of FOS\ElasticaBundle\Index\IndexManager + * Deprecated FOS\ElasticaBundle\Resetter in favour of FOS\ElasticaBundle\Index\Resetter + +* 3.0.0-ALPHA5 (2014-05-23) + + * Doctrine Provider speed up by disabling persistence logging while populating documents + +* 3.0.0-ALPHA4 (2014-04-10) + + * Indexes are now capable of logging errors with Elastica + * Fixed deferred indexing of deleted documents + * Resetting an index will now create it even if it doesn't exist + * Bulk upserting of documents is now supported when populating + +* 3.0.0-ALPHA3 (2014-04-01) + + * a9c4c93: Logger is now only enabled in debug mode by default + * #463: allowing hot swappable reindexing + * #415: BC BREAK: document indexing occurs in postFlush rather than the pre* events previously. + * 7d13823: Dropped (broken) support for Symfony <2.3 + * #496: Added support for HTTP headers + * #528: FOSElasticaBundle will disable Doctrine logging when populating for a large increase in speed + +* 3.0.0-ALPHA2 (2014-03-17) + + * 41bf07e: Renamed the `no-stop-on-error` option in PopulateCommand to `ignore-errors` + * 418b9d7: Fixed validation of url configuration + * 726892c: Ignore TypeMissingException when resetting a single type. This allows to create new types without having to recreate the whole index. + * 7f53bad Add support for include_in_{parent,root} for nested and objects diff --git a/CHANGELOG-3.1.md b/CHANGELOG-3.1.md new file mode 100644 index 0000000..3ca3c77 --- /dev/null +++ b/CHANGELOG-3.1.md @@ -0,0 +1,61 @@ +CHANGELOG for 3.1.x +=================== + +This changelog references the relevant changes (bug and security fixes) done +in 3.1 versions. + +To get the diff for a specific change, go to +https://github.com/FriendsOfSymfony/FOSElasticaBundle/commit/XXX where XXX is +the commit hash. To get the diff between two versions, go to +https://github.com/FriendsOfSymfony/FOSElasticaBundle/compare/v3.0.4...v3.1.0 + +* 3.1.3 (2015-04-02) + + * Fix Symfony 2.3 compatibility + +* 3.1.2 (2015-03-27) + + * Fix the previous release + +* 3.1.1 (2015-03-27) + + * Fix PopulateCommand trying to set formats for ProgressBar in Symfony < 2.5 + * Fix Provider implementations that depend on a batch size from going into + infinite loops + +* 3.1.0 (2015-03-18) + + * BC BREAK: `Doctrine\Listener#scheduleForDeletion` access changed to private. + * BC BREAK: `ObjectPersisterInterface` gains the method `handlesObject` that + returns a boolean value if it will handle a given object or not. + * BC BREAK: Removed `Doctrine\Listener#getSubscribedEvents`. The container + configuration now configures tags with the methods to call to avoid loading + this class on every request where doctrine is active. #729 + * BC BREAK: Added methods for retrieving aggregations when paginating results. + The `PaginationAdapterInterface` has a new method, `getAggregations`. #726 + * Added ability to configure `date_detection`, `numeric_detection` and + `dynamic_date_formats` for types. #753 + * New event `POST_TRANSFORM` which allows developers to add custom properties to + Elastica Documents for indexing. + * When available, the `fos:elastica:populate` command will now use the + ProgressBar helper instead of outputting strings. You can use verbosity + controls on the command to output additional information like memory + usage, runtime and estimated time. + * Added new option `property_path` to a type property definition to allow + customisation of the property path used to retrieve data from objects. + Setting `property_path` to `false` will configure the Transformer to ignore + that property while transforming. Combined with the above POST_TRANSFORM event + developers can now create calculated dynamic properties on Elastica documents + for indexing. #794 + * Fixed a case where ProgressCommand would always ignore errors regardless of + --ignore-errors being passed or not. + * Added a `SliceFetcher` abstraction for Doctrine providers that get more + information about the previous slice allowing for optimising queries during + population. #725 + * New events `PRE_INDEX_POPULATE`, `POST_INDEX_POPULATE`, `PRE_TYPE_POPULATE` and + `POST_TYPE_POPULATE` allow for monitoring when an index is about to be or has + just been populated. #744 + * New events `PRE_INDEX_RESET`, `POST_INDEX_RESET`, `PRE_TYPE_RESET` and + `POST_TYPE_RESET` are run before and after operations that will reset an + index. #744 + * Added indexable callback support for the __invoke method of a service. #823 diff --git a/Client.php b/Client.php index cd30cb2..d0cee46 100644 --- a/Client.php +++ b/Client.php @@ -2,34 +2,11 @@ namespace FOS\ElasticaBundle; -use Elastica_Client; -use FOS\ElasticaBundle\Logger\ElasticaLogger; +use FOS\ElasticaBundle\Elastica\Client as BaseClient; /** - * @author Gordon Franke + * @deprecated Use \FOS\ElasticaBundle\Elastica\LoggingClient */ -class Client extends Elastica_Client +class Client extends BaseClient { - /** - * @var ElasticaLogger - */ - protected $logger; - - public function setLogger(ElasticaLogger $logger) - { - $this->logger = $logger; - } - - public function request($path, $method, $data = array(), array $query = array()) - { - $start = microtime(true); - $response = parent::request($path, $method, $data, $query); - - if (null !== $this->logger) { - $time = microtime(true) - $start; - $this->logger->logQuery($path, $method, $data, $time); - } - - return $response; - } } diff --git a/Command/PopulateCommand.php b/Command/PopulateCommand.php old mode 100755 new mode 100644 index 4f88b75..42af355 --- a/Command/PopulateCommand.php +++ b/Command/PopulateCommand.php @@ -2,27 +2,38 @@ namespace FOS\ElasticaBundle\Command; +use FOS\ElasticaBundle\Event\IndexPopulateEvent; +use FOS\ElasticaBundle\Event\TypePopulateEvent; use Symfony\Bundle\FrameworkBundle\Command\ContainerAwareCommand; -use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Helper\DialogHelper; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Output\Output; use FOS\ElasticaBundle\IndexManager; use FOS\ElasticaBundle\Provider\ProviderRegistry; use FOS\ElasticaBundle\Resetter; -use FOS\ElasticaBundle\Provider\ProviderInterface; +use Symfony\Component\Console\Helper\ProgressBar; /** - * Populate the search index + * Populate the search index. */ class PopulateCommand extends ContainerAwareCommand { + /** + * @var \Symfony\Component\EventDispatcher\EventDispatcherInterface + */ + private $dispatcher; + /** * @var IndexManager */ private $indexManager; + /** + * @var ProgressClosureBuilder + */ + private $progressClosureBuilder; + /** * @var ProviderRegistry */ @@ -43,28 +54,58 @@ class PopulateCommand extends ContainerAwareCommand ->addOption('index', null, InputOption::VALUE_OPTIONAL, 'The index to repopulate') ->addOption('type', null, InputOption::VALUE_OPTIONAL, 'The type to repopulate') ->addOption('no-reset', null, InputOption::VALUE_NONE, 'Do not reset index before populating') + ->addOption('offset', null, InputOption::VALUE_REQUIRED, 'Start indexing at offset', 0) + ->addOption('sleep', null, InputOption::VALUE_REQUIRED, 'Sleep time between persisting iterations (microseconds)', 0) + ->addOption('batch-size', null, InputOption::VALUE_REQUIRED, 'Index packet size (overrides provider config option)') + ->addOption('ignore-errors', null, InputOption::VALUE_NONE, 'Do not stop on errors') + ->addOption('no-overwrite-format', null, InputOption::VALUE_NONE, 'Prevent this command from overwriting ProgressBar\'s formats') ->setDescription('Populates search indexes from providers') ; } /** - * @see Symfony\Component\Console\Command\Command::initialize() + * {@inheritDoc} */ protected function initialize(InputInterface $input, OutputInterface $output) { + $this->dispatcher = $this->getContainer()->get('event_dispatcher'); $this->indexManager = $this->getContainer()->get('fos_elastica.index_manager'); $this->providerRegistry = $this->getContainer()->get('fos_elastica.provider_registry'); $this->resetter = $this->getContainer()->get('fos_elastica.resetter'); + $this->progressClosureBuilder = new ProgressClosureBuilder(); + + if (!$input->getOption('no-overwrite-format') && class_exists('Symfony\\Component\\Console\\Helper\\ProgressBar')) { + ProgressBar::setFormatDefinition('normal', " %current%/%max% [%bar%] %percent:3s%%\n%message%"); + ProgressBar::setFormatDefinition('verbose', " %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%\n%message%"); + ProgressBar::setFormatDefinition('very_verbose', " %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s%\n%message%"); + ProgressBar::setFormatDefinition('debug', " %current%/%max% [%bar%] %percent:3s%% %elapsed:6s%/%estimated:-6s% %memory:6s%\n%message%"); + } } /** - * @see Symfony\Component\Console\Command\Command::execute() + * {@inheritDoc} */ protected function execute(InputInterface $input, OutputInterface $output) { - $index = $input->getOption('index'); - $type = $input->getOption('type'); - $reset = $input->getOption('no-reset') ? false : true; + $index = $input->getOption('index'); + $type = $input->getOption('type'); + $reset = !$input->getOption('no-reset'); + $options = array( + 'ignore_errors' => $input->getOption('ignore-errors'), + 'offset' => $input->getOption('offset'), + 'sleep' => $input->getOption('sleep') + ); + if ($input->getOption('batch-size')) { + $options['batch_size'] = (int) $input->getOption('batch-size'); + } + + if ($input->isInteractive() && $reset && $input->getOption('offset')) { + /** @var DialogHelper $dialog */ + $dialog = $this->getHelperSet()->get('dialog'); + if (!$dialog->askConfirmation($output, 'You chose to reset the index and start indexing with an offset. Do you really want to do that?', true)) { + return; + } + } if (null === $index && null !== $type) { throw new \InvalidArgumentException('Cannot specify type option without an index.'); @@ -72,15 +113,15 @@ class PopulateCommand extends ContainerAwareCommand if (null !== $index) { if (null !== $type) { - $this->populateIndexType($output, $index, $type, $reset); + $this->populateIndexType($output, $index, $type, $reset, $options); } else { - $this->populateIndex($output, $index, $reset); + $this->populateIndex($output, $index, $reset, $options); } } else { $indexes = array_keys($this->indexManager->getAllIndexes()); foreach ($indexes as $index) { - $this->populateIndex($output, $index, $reset); + $this->populateIndex($output, $index, $reset, $options); } } } @@ -91,27 +132,26 @@ class PopulateCommand extends ContainerAwareCommand * @param OutputInterface $output * @param string $index * @param boolean $reset + * @param array $options */ - private function populateIndex(OutputInterface $output, $index, $reset) + private function populateIndex(OutputInterface $output, $index, $reset, $options) { - if ($reset) { + $event = new IndexPopulateEvent($index, $reset, $options); + $this->dispatcher->dispatch(IndexPopulateEvent::PRE_INDEX_POPULATE, $event); + + if ($event->isReset()) { $output->writeln(sprintf('Resetting %s', $index)); - $this->resetter->resetIndex($index); + $this->resetter->resetIndex($index, true); } - /** @var $providers ProviderInterface[] */ - $providers = $this->providerRegistry->getIndexProviders($index); - - foreach ($providers as $type => $provider) { - $loggerClosure = function($message) use ($output, $index, $type) { - $output->writeln(sprintf('Populating %s/%s, %s', $index, $type, $message)); - }; - - $provider->populate($loggerClosure); + $types = array_keys($this->providerRegistry->getIndexProviders($index)); + foreach ($types as $type) { + $this->populateIndexType($output, $index, $type, false, $event->getOptions()); } - $output->writeln(sprintf('Refreshing %s', $index)); - $this->indexManager->getIndex($index)->refresh(); + $this->dispatcher->dispatch(IndexPopulateEvent::POST_INDEX_POPULATE, $event); + + $this->refreshIndex($output, $index); } /** @@ -121,22 +161,41 @@ class PopulateCommand extends ContainerAwareCommand * @param string $index * @param string $type * @param boolean $reset + * @param array $options */ - private function populateIndexType(OutputInterface $output, $index, $type, $reset) + private function populateIndexType(OutputInterface $output, $index, $type, $reset, $options) { - if ($reset) { - $output->writeln(sprintf('Resetting: %s/%s', $index, $type)); + $event = new TypePopulateEvent($index, $type, $reset, $options); + $this->dispatcher->dispatch(TypePopulateEvent::PRE_TYPE_POPULATE, $event); + + if ($event->isReset()) { + $output->writeln(sprintf('Resetting %s/%s', $index, $type)); $this->resetter->resetIndexType($index, $type); } - $loggerClosure = function($message) use ($output, $index, $type) { - $output->writeln(sprintf('Populating: %s/%s, %s', $index, $type, $message)); - }; - $provider = $this->providerRegistry->getProvider($index, $type); - $provider->populate($loggerClosure); + $loggerClosure = $this->progressClosureBuilder->build($output, 'Populating', $index, $type); + $provider->populate($loggerClosure, $event->getOptions()); - $output->writeln(sprintf('Refreshing: %s', $index)); + $this->dispatcher->dispatch(TypePopulateEvent::POST_TYPE_POPULATE, $event); + + $this->refreshIndex($output, $index, false); + } + + /** + * Refreshes an index. + * + * @param OutputInterface $output + * @param string $index + * @param bool $postPopulate + */ + private function refreshIndex(OutputInterface $output, $index, $postPopulate = true) + { + if ($postPopulate) { + $this->resetter->postPopulate($index); + } + + $output->writeln(sprintf('Refreshing %s', $index)); $this->indexManager->getIndex($index)->refresh(); } } diff --git a/Command/ProgressClosureBuilder.php b/Command/ProgressClosureBuilder.php new file mode 100644 index 0000000..f244bc3 --- /dev/null +++ b/Command/ProgressClosureBuilder.php @@ -0,0 +1,103 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Command; + +use Symfony\Component\Console\Helper\ProgressBar; +use Symfony\Component\Console\Output\OutputInterface; + +class ProgressClosureBuilder +{ + /** + * Builds a loggerClosure to be called from inside the Provider to update the command + * line. + * + * @param OutputInterface $output + * @param string $action + * @param string $index + * @param string $type + * + * @return callable + */ + public function build(OutputInterface $output, $action, $index, $type) + { + if (!class_exists('Symfony\Component\Console\Helper\ProgressBar') || + !is_callable(array('Symfony\Component\Console\Helper\ProgressBar', 'getProgress'))) { + return $this->buildLegacy($output, $action, $index, $type); + } + + $progress = null; + + return function ($increment, $totalObjects, $message = null) use (&$progress, $output, $action, $index, $type) { + if (null === $progress) { + $progress = new ProgressBar($output, $totalObjects); + $progress->start(); + } + + if (null !== $message) { + $progress->clear(); + $output->writeln(sprintf('%s %s', $action, $message)); + $progress->display(); + } + + $progress->setMessage(sprintf('%s %s/%s', $action, $index, $type)); + $progress->advance($increment); + }; + } + + /** + * Builds a legacy closure that outputs lines for each step. Used in cases + * where the ProgressBar component doesnt exist or does not have the correct + * methods to support what we need. + * + * @param OutputInterface $output + * @param string $action + * @param string $index + * @param string $type + * + * @return callable + */ + private function buildLegacy(OutputInterface $output, $action, $index, $type) + { + $lastStep = null; + $current = 0; + + return function ($increment, $totalObjects, $message = null) use ($output, $action, $index, $type, &$lastStep, &$current) { + if ($current + $increment > $totalObjects) { + $increment = $totalObjects - $current; + } + + if (null !== $message) { + $output->writeln(sprintf('%s %s', $action, $message)); + } + + $currentTime = microtime(true); + $timeDifference = $currentTime - $lastStep; + $objectsPerSecond = $lastStep ? ($increment / $timeDifference) : $increment; + $lastStep = $currentTime; + $current += $increment; + $percent = 100 * $current / $totalObjects; + + $output->writeln(sprintf( + '%s %s/%s %0.1f%% (%d/%d), %d objects/s (RAM: current=%uMo peak=%uMo)', + $action, + $index, + $type, + $percent, + $current, + $totalObjects, + $objectsPerSecond, + round(memory_get_usage() / (1024 * 1024)), + round(memory_get_peak_usage() / (1024 * 1024)) + )); + }; + } +} diff --git a/Command/ResetCommand.php b/Command/ResetCommand.php new file mode 100755 index 0000000..85c5483 --- /dev/null +++ b/Command/ResetCommand.php @@ -0,0 +1,78 @@ +setName('fos:elastica:reset') + ->addOption('index', null, InputOption::VALUE_OPTIONAL, 'The index to reset') + ->addOption('type', null, InputOption::VALUE_OPTIONAL, 'The type to reset') + ->addOption('force', null, InputOption::VALUE_NONE, 'Force index deletion if same name as alias') + ->setDescription('Reset search indexes') + ; + } + + /** + * @see Symfony\Component\Console\Command\Command::initialize() + */ + protected function initialize(InputInterface $input, OutputInterface $output) + { + $this->indexManager = $this->getContainer()->get('fos_elastica.index_manager'); + $this->resetter = $this->getContainer()->get('fos_elastica.resetter'); + } + + /** + * @see Symfony\Component\Console\Command\Command::execute() + */ + protected function execute(InputInterface $input, OutputInterface $output) + { + $index = $input->getOption('index'); + $type = $input->getOption('type'); + $force = (bool) $input->getOption('force'); + + if (null === $index && null !== $type) { + throw new \InvalidArgumentException('Cannot specify type option without an index.'); + } + + if (null !== $type) { + $output->writeln(sprintf('Resetting %s/%s', $index, $type)); + $this->resetter->resetIndexType($index, $type); + } else { + $indexes = null === $index + ? array_keys($this->indexManager->getAllIndexes()) + : array($index) + ; + + foreach ($indexes as $index) { + $output->writeln(sprintf('Resetting %s', $index)); + $this->resetter->resetIndex($index, false, $force); + } + } + } +} diff --git a/Command/SearchCommand.php b/Command/SearchCommand.php index acf9449..11183de 100644 --- a/Command/SearchCommand.php +++ b/Command/SearchCommand.php @@ -7,12 +7,11 @@ use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\Console\Output\Output; -use Elastica_Query; -use Elastica_Result; +use Elastica\Query; +use Elastica\Result; /** - * Searches a type + * Searches a type. */ class SearchCommand extends ContainerAwareCommand { @@ -41,11 +40,11 @@ class SearchCommand extends ContainerAwareCommand protected function execute(InputInterface $input, OutputInterface $output) { $indexName = $input->getOption('index'); - /** @var $index \Elastica_Index */ + /** @var $index \Elastica\Index */ $index = $this->getContainer()->get('fos_elastica.index_manager')->getIndex($indexName ? $indexName : null); $type = $index->getType($input->getArgument('type')); - $query = Elastica_Query::create($input->getArgument('query')); - $query->setLimit($input->getOption('limit')); + $query = Query::create($input->getArgument('query')); + $query->setSize($input->getOption('limit')); if ($input->getOption('explain')) { $query->setExplain(true); } @@ -58,7 +57,7 @@ class SearchCommand extends ContainerAwareCommand } } - protected function formatResult(Elastica_Result $result, $showField, $showSource, $showId, $explain) + protected function formatResult(Result $result, $showField, $showSource, $showId, $explain) { $source = $result->getSource(); if ($showField) { diff --git a/Configuration/ConfigManager.php b/Configuration/ConfigManager.php new file mode 100644 index 0000000..bef061b --- /dev/null +++ b/Configuration/ConfigManager.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Configuration; + +/** + * Central manager for index and type configuration. + */ +class ConfigManager implements ManagerInterface +{ + /** + * @var IndexConfig[] + */ + private $indexes = array(); + + /** + * @param Source\SourceInterface[] $sources + */ + public function __construct(array $sources) + { + foreach ($sources as $source) { + $this->indexes = array_merge($source->getConfiguration(), $this->indexes); + } + } + + public function getIndexConfiguration($indexName) + { + if (!$this->hasIndexConfiguration($indexName)) { + throw new \InvalidArgumentException(sprintf('Index with name "%s" is not configured.', $indexName)); + } + + return $this->indexes[$indexName]; + } + + public function getIndexNames() + { + return array_keys($this->indexes); + } + + public function getTypeConfiguration($indexName, $typeName) + { + $index = $this->getIndexConfiguration($indexName); + $type = $index->getType($typeName); + + if (!$type) { + throw new \InvalidArgumentException(sprintf('Type with name "%s" on index "%s" is not configured', $typeName, $indexName)); + } + + return $type; + } + + public function hasIndexConfiguration($indexName) + { + return isset($this->indexes[$indexName]); + } +} diff --git a/Configuration/IndexConfig.php b/Configuration/IndexConfig.php new file mode 100644 index 0000000..749b10d --- /dev/null +++ b/Configuration/IndexConfig.php @@ -0,0 +1,124 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Configuration; + +class IndexConfig +{ + /** + * The name of the index for ElasticSearch. + * + * @var string + */ + private $elasticSearchName; + + /** + * The internal name of the index. May not be the same as the name used in ElasticSearch, + * especially if aliases are enabled. + * + * @var string + */ + private $name; + + /** + * An array of settings sent to ElasticSearch when creating the index. + * + * @var array + */ + private $settings; + + /** + * All types that belong to this index. + * + * @var TypeConfig[] + */ + private $types; + + /** + * Indicates if the index should use an alias, allowing an index repopulation to occur + * without overwriting the current index. + * + * @var bool + */ + private $useAlias = false; + + /** + * Constructor expects an array as generated by the Container Configuration builder. + * + * @param string $name + * @param TypeConfig[] $types + * @param array $config + */ + public function __construct($name, array $types, array $config) + { + $this->elasticSearchName = isset($config['elasticSearchName']) ? $config['elasticSearchName'] : $name; + $this->name = $name; + $this->settings = isset($config['settings']) ? $config['settings'] : array(); + $this->types = $types; + $this->useAlias = isset($config['useAlias']) ? $config['useAlias'] : false; + } + + /** + * @return string + */ + public function getElasticSearchName() + { + return $this->elasticSearchName; + } + + /** + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @return array + */ + public function getSettings() + { + return $this->settings; + } + + /** + * @param string $typeName + * + * @return TypeConfig + * + * @throws \InvalidArgumentException + */ + public function getType($typeName) + { + if (!array_key_exists($typeName, $this->types)) { + throw new \InvalidArgumentException(sprintf('Type "%s" does not exist on index "%s"', $typeName, $this->name)); + } + + return $this->types[$typeName]; + } + + /** + * @return \FOS\ElasticaBundle\Configuration\TypeConfig[] + */ + public function getTypes() + { + return $this->types; + } + + /** + * @return boolean + */ + public function isUseAlias() + { + return $this->useAlias; + } +} diff --git a/Configuration/ManagerInterface.php b/Configuration/ManagerInterface.php new file mode 100644 index 0000000..742df1b --- /dev/null +++ b/Configuration/ManagerInterface.php @@ -0,0 +1,44 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Configuration; + +/** + * Central manager for index and type configuration. + */ +interface ManagerInterface +{ + /** + * Returns configuration for an index. + * + * @param $index + * + * @return IndexConfig + */ + public function getIndexConfiguration($index); + + /** + * Returns an array of known index names. + * + * @return array + */ + public function getIndexNames(); + + /** + * Returns a type configuration. + * + * @param string $index + * @param string $type + * + * @return TypeConfig + */ + public function getTypeConfiguration($index, $type); +} diff --git a/Configuration/Search.php b/Configuration/Search.php index ded65a5..1d046c0 100644 --- a/Configuration/Search.php +++ b/Configuration/Search.php @@ -1,18 +1,26 @@ + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + namespace FOS\ElasticaBundle\Configuration; +use FOS\ElasticaBundle\Annotation\Search as BaseSearch; + /** * Annotation class for setting search repository. * - * @author Richard Miller * @Annotation + * + * @deprecated Use FOS\ElasticaBundle\Annotation\Search instead * @Target("CLASS") */ -class Search +class Search extends BaseSearch { - - /** @var string */ - public $repositoryClass; - } diff --git a/Configuration/Source/ContainerSource.php b/Configuration/Source/ContainerSource.php new file mode 100644 index 0000000..25e6f86 --- /dev/null +++ b/Configuration/Source/ContainerSource.php @@ -0,0 +1,80 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Configuration\Source; + +use FOS\ElasticaBundle\Configuration\IndexConfig; +use FOS\ElasticaBundle\Configuration\TypeConfig; + +/** + * Returns index and type configuration from the container. + */ +class ContainerSource implements SourceInterface +{ + /** + * The internal container representation of information. + * + * @var array + */ + private $configArray; + + public function __construct(array $configArray) + { + $this->configArray = $configArray; + } + + /** + * Should return all configuration available from the data source. + * + * @return IndexConfig[] + */ + public function getConfiguration() + { + $indexes = array(); + foreach ($this->configArray as $config) { + $types = $this->getTypes($config); + $index = new IndexConfig($config['name'], $types, array( + 'elasticSearchName' => $config['elasticsearch_name'], + 'settings' => $config['settings'], + 'useAlias' => $config['use_alias'], + )); + + $indexes[$config['name']] = $index; + } + + return $indexes; + } + + /** + * Builds TypeConfig objects for each type. + * + * @param array $config + * + * @return array + */ + protected function getTypes($config) + { + $types = array(); + + if (isset($config['types'])) { + foreach ($config['types'] as $typeConfig) { + $types[$typeConfig['name']] = new TypeConfig( + $typeConfig['name'], + $typeConfig['mapping'], + $typeConfig['config'] + ); + // TODO: handle prototypes.. + } + } + + return $types; + } +} diff --git a/Configuration/Source/SourceInterface.php b/Configuration/Source/SourceInterface.php new file mode 100644 index 0000000..05a64d0 --- /dev/null +++ b/Configuration/Source/SourceInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Configuration\Source; + +/** + * Represents a source of index and type information (ie, the Container configuration or + * annotations). + */ +interface SourceInterface +{ + /** + * Should return all configuration available from the data source. + * + * @return \FOS\ElasticaBundle\Configuration\IndexConfig[] + */ + public function getConfiguration(); +} diff --git a/Configuration/TypeConfig.php b/Configuration/TypeConfig.php new file mode 100644 index 0000000..a46cd34 --- /dev/null +++ b/Configuration/TypeConfig.php @@ -0,0 +1,113 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Configuration; + +class TypeConfig +{ + /** + * @var array + */ + private $config; + + /** + * @var array + */ + private $mapping; + + /** + * @var string + */ + private $name; + + public function __construct($name, array $mapping, array $config = array()) + { + $this->config = $config; + $this->mapping = $mapping; + $this->name = $name; + } + + /** + * @return bool|null + */ + public function getDateDetection() + { + return $this->getConfig('date_detection'); + } + + /** + * @return array + */ + public function getDynamicDateFormats() + { + return $this->getConfig('dynamic_date_formats'); + } + + /** + * @return string|null + */ + public function getIndexAnalyzer() + { + return $this->getConfig('index_analyzer'); + } + + /** + * @return array + */ + public function getMapping() + { + return $this->mapping; + } + + /** + * @return string|null + */ + public function getModel() + { + return isset($this->config['persistence']['model']) ? + $this->config['persistence']['model'] : + null; + } + + /** + * @return bool|null + */ + public function getNumericDetection() + { + return $this->getConfig('numeric_detection'); + } + + /** + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @return string|null + */ + public function getSearchAnalyzer() + { + return $this->getConfig('search_analyzer'); + } + + /** + * @param string $key + */ + private function getConfig($key) + { + return isset($this->config[$key]) ? + $this->config[$key] : + null; + } +} diff --git a/DependencyInjection/Compiler/ConfigSourcePass.php b/DependencyInjection/Compiler/ConfigSourcePass.php new file mode 100644 index 0000000..92a2489 --- /dev/null +++ b/DependencyInjection/Compiler/ConfigSourcePass.php @@ -0,0 +1,36 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; + +class ConfigSourcePass implements CompilerPassInterface +{ + /** + * {@inheritDoc} + */ + public function process(ContainerBuilder $container) + { + if (!$container->hasDefinition('fos_elastica.config_manager')) { + return; + } + + $sources = array(); + foreach (array_keys($container->findTaggedServiceIds('fos_elastica.config_source')) as $id) { + $sources[] = new Reference($id); + } + + $container->getDefinition('fos_elastica.config_manager')->replaceArgument(0, $sources); + } +} diff --git a/DependencyInjection/Compiler/IndexPass.php b/DependencyInjection/Compiler/IndexPass.php new file mode 100644 index 0000000..e131214 --- /dev/null +++ b/DependencyInjection/Compiler/IndexPass.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\DependencyInjection\Compiler; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; + +class IndexPass implements CompilerPassInterface +{ + /** + * {@inheritDoc} + */ + public function process(ContainerBuilder $container) + { + if (!$container->hasDefinition('fos_elastica.index_manager')) { + return; + } + + $indexes = array(); + foreach ($container->findTaggedServiceIds('fos_elastica.index') as $id => $tags) { + foreach ($tags as $tag) { + $indexes[$tag['name']] = new Reference($id); + } + } + + $container->getDefinition('fos_elastica.index_manager')->replaceArgument(0, $indexes); + } +} diff --git a/DependencyInjection/Compiler/RegisterProvidersPass.php b/DependencyInjection/Compiler/RegisterProvidersPass.php index c6c9e6e..4fd25b0 100644 --- a/DependencyInjection/Compiler/RegisterProvidersPass.php +++ b/DependencyInjection/Compiler/RegisterProvidersPass.php @@ -55,6 +55,7 @@ class RegisterProvidersPass implements CompilerPassInterface * Returns whether the class implements ProviderInterface. * * @param string $class + * * @return boolean */ private function isProviderImplementation($class) diff --git a/DependencyInjection/Compiler/TransformerPass.php b/DependencyInjection/Compiler/TransformerPass.php index f94b95f..596c732 100644 --- a/DependencyInjection/Compiler/TransformerPass.php +++ b/DependencyInjection/Compiler/TransformerPass.php @@ -19,7 +19,7 @@ class TransformerPass implements CompilerPassInterface */ public function process(ContainerBuilder $container) { - if (!$container->hasDefinition('fos_elastica.elastica_to_model_transformer.collection.prototype')) { + if (!$container->hasDefinition('fos_elastica.elastica_to_model_transformer.collection')) { return; } @@ -31,7 +31,7 @@ class TransformerPass implements CompilerPassInterface throw new InvalidArgumentException('The Transformer must have both a type and an index defined.'); } - $transformers[$tag['index']][$tag['type']]= new Reference($id); + $transformers[$tag['index']][$tag['type']] = new Reference($id); } } @@ -44,4 +44,4 @@ class TransformerPass implements CompilerPassInterface $index->replaceArgument(0, $indexTransformers); } } -} \ No newline at end of file +} diff --git a/DependencyInjection/Configuration.php b/DependencyInjection/Configuration.php index 7872e90..1391eaa 100644 --- a/DependencyInjection/Configuration.php +++ b/DependencyInjection/Configuration.php @@ -8,18 +8,29 @@ use Symfony\Component\Config\Definition\ConfigurationInterface; class Configuration implements ConfigurationInterface { + /** + * Stores supported database drivers. + * + * @var array + */ private $supportedDrivers = array('orm', 'mongodb', 'propel'); - private $configArray = array(); + /** + * If the kernel is running in debug mode. + * + * @var bool + */ + private $debug; - public function __construct($configArray){ - $this->configArray = $configArray; + public function __construct($debug) + { + $this->debug = $debug; } /** * Generates the configuration tree. * - * @return \Symfony\Component\Config\Definition\NodeInterface + * @return TreeBuilder */ public function getConfigTreeBuilder() { @@ -31,9 +42,20 @@ class Configuration implements ConfigurationInterface $rootNode ->children() - ->scalarNode('default_client')->end() - ->scalarNode('default_index')->end() + ->scalarNode('default_client') + ->info('Defaults to the first client defined') + ->end() + ->scalarNode('default_index') + ->info('Defaults to the first index defined') + ->end() ->scalarNode('default_manager')->defaultValue('orm')->end() + ->arrayNode('serializer') + ->treatNullLike(array()) + ->children() + ->scalarNode('callback_class')->defaultValue('FOS\ElasticaBundle\Serializer\Callback')->end() + ->scalarNode('serializer')->defaultValue('serializer')->end() + ->end() + ->end() ->end() ; @@ -41,17 +63,7 @@ class Configuration implements ConfigurationInterface } /** - * Generates the configuration tree. - * - * @return \Symfony\Component\DependencyInjection\Configuration\NodeInterface - */ - public function getConfigTree() - { - return $this->getConfigTreeBuilder()->buildTree(); - } - - /** - * Adds the configuration for the "clients" key + * Adds the configuration for the "clients" key. */ private function addClientsSection(ArrayNodeDefinition $rootNode) { @@ -62,43 +74,67 @@ class Configuration implements ConfigurationInterface ->useAttributeAsKey('id') ->prototype('array') ->performNoDeepMerging() + // BC - Renaming 'servers' node to 'connections' ->beforeNormalization() - ->ifTrue(function($v) { return isset($v['host']) && isset($v['port']); }) - ->then(function($v) { - return array( - 'servers' => array( - array( - 'host' => $v['host'], - 'port' => $v['port'], - ) - ) - ); - }) + ->ifTrue(function ($v) { return isset($v['servers']); }) + ->then(function ($v) { + $v['connections'] = $v['servers']; + unset($v['servers']); + + return $v; + }) ->end() + // Elastica names its properties with camel case, support both ->beforeNormalization() - ->ifTrue(function($v) { return isset($v['url']); }) - ->then(function($v) { - return array( - 'servers' => array( - array( - 'url' => $v['url'], - ) - ) - ); - }) + ->ifTrue(function ($v) { return isset($v['connection_strategy']); }) + ->then(function ($v) { + $v['connectionStrategy'] = $v['connection_strategy']; + unset($v['connection_strategy']); + + return $v; + }) + ->end() + // If there is no connections array key defined, assume a single connection. + ->beforeNormalization() + ->ifTrue(function ($v) { return is_array($v) && !array_key_exists('connections', $v); }) + ->then(function ($v) { + return array( + 'connections' => array($v), + ); + }) ->end() ->children() - ->arrayNode('servers') + ->arrayNode('connections') + ->requiresAtLeastOneElement() ->prototype('array') + ->fixXmlConfig('header') ->children() - ->scalarNode('url')->end() + ->scalarNode('url') + ->validate() + ->ifTrue(function ($url) { return $url && substr($url, -1) !== '/'; }) + ->then(function ($url) { return $url.'/'; }) + ->end() + ->end() ->scalarNode('host')->end() ->scalarNode('port')->end() + ->scalarNode('proxy')->end() + ->scalarNode('logger') + ->defaultValue($this->debug ? 'fos_elastica.logger' : false) + ->treatNullLike('fos_elastica.logger') + ->treatTrueLike('fos_elastica.logger') + ->end() + ->arrayNode('headers') + ->useAttributeAsKey('name') + ->prototype('scalar')->end() + ->end() + ->scalarNode('transport')->end() + ->scalarNode('timeout')->end() ->end() ->end() ->end() ->scalarNode('timeout')->end() ->scalarNode('headers')->end() + ->scalarNode('connectionStrategy')->defaultValue('Simple')->end() ->end() ->end() ->end() @@ -107,7 +143,7 @@ class Configuration implements ConfigurationInterface } /** - * Adds the configuration for the "indexes" key + * Adds the configuration for the "indexes" key. */ private function addIndexesSection(ArrayNodeDefinition $rootNode) { @@ -117,9 +153,11 @@ class Configuration implements ConfigurationInterface ->arrayNode('indexes') ->useAttributeAsKey('name') ->prototype('array') - ->performNoDeepMerging() ->children() - ->scalarNode('index_name')->end() + ->scalarNode('index_name') + ->info('Defaults to the name of the index, but can be modified if the index name is different in ElasticSearch') + ->end() + ->booleanNode('use_alias')->defaultValue(false)->end() ->scalarNode('client')->end() ->scalarNode('finder') ->treatNullLike(true) @@ -129,58 +167,8 @@ class Configuration implements ConfigurationInterface ->children() ->scalarNode('index_analyzer')->end() ->scalarNode('search_analyzer')->end() - ->arrayNode('persistence') - ->validate() - ->ifTrue(function($v) { return isset($v['driver']) && 'propel' === $v['driver'] && isset($v['listener']); }) - ->thenInvalid('Propel doesn\'t support listeners') - ->ifTrue(function($v) { return isset($v['driver']) && 'propel' === $v['driver'] && isset($v['repository']); }) - ->thenInvalid('Propel doesn\'t support the "repository" parameter') - ->end() - ->children() - ->scalarNode('driver') - ->validate() - ->ifNotInArray($this->supportedDrivers) - ->thenInvalid('The driver %s is not supported. Please choose one of '.json_encode($this->supportedDrivers)) - ->end() - ->end() - ->scalarNode('identifier')->defaultValue('id')->end() - ->arrayNode('provider') - ->children() - ->scalarNode('query_builder_method')->defaultValue('createQueryBuilder')->end() - ->scalarNode('batch_size')->defaultValue(100)->end() - ->scalarNode('clear_object_manager')->defaultTrue()->end() - ->scalarNode('service')->end() - ->end() - ->end() - ->arrayNode('listener') - ->children() - ->scalarNode('insert')->defaultTrue()->end() - ->scalarNode('update')->defaultTrue()->end() - ->scalarNode('delete')->defaultTrue()->end() - ->scalarNode('service')->end() - ->variableNode('is_indexable_callback')->defaultNull()->end() - ->end() - ->end() - ->arrayNode('finder') - ->children() - ->scalarNode('service')->end() - ->end() - ->end() - ->arrayNode('elastica_to_model_transformer') - ->addDefaultsIfNotSet() - ->children() - ->scalarNode('hydrate')->defaultTrue()->end() - ->scalarNode('service')->end() - ->end() - ->end() - ->arrayNode('model_to_elastica_transformer') - ->addDefaultsIfNotSet() - ->children() - ->scalarNode('service')->end() - ->end() - ->end() - ->end() - ->end() + ->append($this->getPersistenceNode()) + ->append($this->getSerializerNode()) ->end() ->end() ->variableNode('settings')->defaultValue(array())->end() @@ -204,68 +192,83 @@ class Configuration implements ConfigurationInterface ->useAttributeAsKey('name') ->prototype('array') ->treatNullLike(array()) - ->children() - ->scalarNode('index_analyzer')->end() - ->scalarNode('search_analyzer')->end() - ->arrayNode('persistence') - ->validate() - ->ifTrue(function($v) { return isset($v['driver']) && 'propel' === $v['driver'] && isset($v['listener']); }) - ->thenInvalid('Propel doesn\'t support listeners') - ->ifTrue(function($v) { return isset($v['driver']) && 'propel' === $v['driver'] && isset($v['repository']); }) - ->thenInvalid('Propel doesn\'t support the "repository" parameter') - ->end() - ->children() - ->scalarNode('driver') - ->validate() - ->ifNotInArray($this->supportedDrivers) - ->thenInvalid('The driver %s is not supported. Please choose one of '.json_encode($this->supportedDrivers)) - ->end() - ->end() - ->scalarNode('model')->end() - ->scalarNode('repository')->end() - ->scalarNode('identifier')->defaultValue('id')->end() - ->arrayNode('provider') - ->children() - ->scalarNode('query_builder_method')->defaultValue('createQueryBuilder')->end() - ->scalarNode('batch_size')->defaultValue(100)->end() - ->scalarNode('clear_object_manager')->defaultTrue()->end() - ->scalarNode('service')->end() - ->end() - ->end() - ->arrayNode('listener') - ->children() - ->scalarNode('insert')->defaultTrue()->end() - ->scalarNode('update')->defaultTrue()->end() - ->scalarNode('delete')->defaultTrue()->end() - ->scalarNode('service')->end() - ->variableNode('is_indexable_callback')->defaultNull()->end() - ->end() - ->end() - ->arrayNode('finder') - ->children() - ->scalarNode('service')->end() - ->end() - ->end() - ->arrayNode('elastica_to_model_transformer') - ->addDefaultsIfNotSet() - ->children() - ->scalarNode('hydrate')->defaultTrue()->end() - ->scalarNode('service')->end() - ->end() - ->end() - ->arrayNode('model_to_elastica_transformer') - ->addDefaultsIfNotSet() - ->children() - ->scalarNode('service')->end() - ->end() - ->end() - ->end() - ->end() + ->beforeNormalization() + ->ifNull() + ->thenEmptyArray() ->end() - ->append($this->getMappingsNode()) + // BC - Renaming 'mappings' node to 'properties' + ->beforeNormalization() + ->ifTrue(function ($v) { return array_key_exists('mappings', $v); }) + ->then(function ($v) { + $v['properties'] = $v['mappings']; + unset($v['mappings']); + + return $v; + }) + ->end() + // BC - Support the old is_indexable_callback property + ->beforeNormalization() + ->ifTrue(function ($v) { + return isset($v['persistence']) && + isset($v['persistence']['listener']) && + isset($v['persistence']['listener']['is_indexable_callback']); + }) + ->then(function ($v) { + $callback = $v['persistence']['listener']['is_indexable_callback']; + + if (is_array($callback)) { + list($class) = $callback + array(null); + + if ($class[0] !== '@' && is_string($class) && !class_exists($class)) { + $callback[0] = '@'.$class; + } + } + + $v['indexable_callback'] = $callback; + unset($v['persistence']['listener']['is_indexable_callback']); + + return $v; + }) + ->end() + // Support multiple dynamic_template formats to match the old bundle style + // and the way ElasticSearch expects them + ->beforeNormalization() + ->ifTrue(function ($v) { return isset($v['dynamic_templates']); }) + ->then(function ($v) { + $dt = array(); + foreach ($v['dynamic_templates'] as $key => $type) { + if (is_int($key)) { + $dt[] = $type; + } else { + $dt[][$key] = $type; + } + } + + $v['dynamic_templates'] = $dt; + + return $v; + }) + ->end() + ->children() + ->booleanNode('date_detection')->end() + ->arrayNode('dynamic_date_formats')->prototype('scalar')->end()->end() + ->scalarNode('index_analyzer')->end() + ->booleanNode('numeric_detection')->end() + ->scalarNode('search_analyzer')->end() + ->variableNode('indexable_callback')->end() + ->append($this->getPersistenceNode()) + ->append($this->getSerializerNode()) + ->end() + ->append($this->getIdNode()) + ->append($this->getPropertiesNode()) + ->append($this->getDynamicTemplateNode()) ->append($this->getSourceNode()) ->append($this->getBoostNode()) ->append($this->getRoutingNode()) + ->append($this->getParentNode()) + ->append($this->getAllNode()) + ->append($this->getTimestampNode()) + ->append($this->getTtlNode()) ->end() ; @@ -273,143 +276,67 @@ class Configuration implements ConfigurationInterface } /** - * Returns the array node used for "mappings". + * Returns the array node used for "properties". */ - protected function getMappingsNode() + protected function getPropertiesNode() { $builder = new TreeBuilder(); - $node = $builder->root('mappings'); + $node = $builder->root('properties'); - $nestings = $this->getNestings(); - - $childrenNode = $node + $node ->useAttributeAsKey('name') - ->prototype('array') - ->treatNullLike(array()) - ->addDefaultsIfNotSet() - ->children(); - - $this->addFieldConfig($childrenNode, $nestings); + ->prototype('variable') + ->treatNullLike(array()); return $node; } /** - * @param \Symfony\Component\Config\Definition\Builder\NodeBuilder $node The node to which to attach the field config to - * @param array $nestings the nested mappings for the current field level + * Returns the array node used for "dynamic_templates". */ - protected function addFieldConfig($node, $nestings) + public function getDynamicTemplateNode() { + $builder = new TreeBuilder(); + $node = $builder->root('dynamic_templates'); + $node - ->scalarNode('type')->defaultValue('string')->end() - ->scalarNode('boost')->end() - ->scalarNode('store')->end() - ->scalarNode('index')->end() - ->scalarNode('index_analyzer')->end() - ->scalarNode('search_analyzer')->end() - ->scalarNode('analyzer')->end() - ->scalarNode('term_vector')->end() - ->scalarNode('null_value')->end() - ->booleanNode('include_in_all')->defaultValue(true)->end() - ->scalarNode('lat_lon')->end() - ->scalarNode('index_name')->end() - ->booleanNode('omit_norms')->end() - ->scalarNode('index_options')->end() - ->scalarNode('ignore_above')->end() - ->scalarNode('position_offset_gap')->end() - ->arrayNode('_parent') - ->treatNullLike(array()) - ->children() - ->scalarNode('type')->end() - ->scalarNode('identifier')->defaultValue('id')->end() - ->end() - ->end(); - - if (isset($nestings['fields'])) { - $this->addNestedFieldConfig($node, $nestings, 'fields'); - } - - if (isset($nestings['properties'])) { - $this->addNestedFieldConfig($node, $nestings, 'properties'); - } - } - - /** - * @param \Symfony\Component\Config\Definition\Builder\NodeBuilder $node The node to which to attach the nested config to - * @param array $nestings The nestings for the current field level - * @param string $property the name of the nested property ('fields' or 'properties') - */ - protected function addNestedFieldConfig($node, $nestings, $property) - { - $childrenNode = $node - ->arrayNode($property) - ->useAttributeAsKey('name') + ->prototype('array') ->prototype('array') - ->treatNullLike(array()) - ->addDefaultsIfNotSet() - ->children(); - - $this->addFieldConfig($childrenNode, $nestings[$property]); - - $childrenNode + ->children() + ->scalarNode('match')->end() + ->scalarNode('unmatch')->end() + ->scalarNode('match_mapping_type')->end() + ->scalarNode('path_match')->end() + ->scalarNode('path_unmatch')->end() + ->scalarNode('match_pattern')->end() + ->arrayNode('mapping') + ->prototype('variable') + ->treatNullLike(array()) + ->end() + ->end() ->end() ->end() ->end() ; + + return $node; } /** - * @return array The unique nested mappings for all types + * Returns the array node used for "_id". */ - protected function getNestings() + protected function getIdNode() { - $nestings = array(); - foreach ($this->configArray[0]['indexes'] as $index) { - if (empty($index['types'])) { - continue; - } + $builder = new TreeBuilder(); + $node = $builder->root('_id'); - foreach ($index['types'] as $type) { - $nestings = array_merge_recursive($nestings, $this->getNestingsForType($type['mappings'], $nestings)); - } - } - return $nestings; - } + $node + ->children() + ->scalarNode('path')->end() + ->end() + ; - /** - * @param array $mappings The mappings for the current type - * @return array The nested mappings defined for this type - */ - protected function getNestingsForType(array $mappings = null) - { - if ($mappings === null) { - return array(); - } - - $nestings = array(); - - foreach ($mappings as $field) { - if (isset($field['fields'])) { - $this->addPropertyNesting($field, $nestings, 'fields'); - } else if (isset($field['properties'])) { - $this->addPropertyNesting($field, $nestings, 'properties'); - } - } - - return $nestings; - } - - /** - * @param array $field The field mapping definition - * @param array $nestings The nestings array - * @param string $property The nested property name ('fields' or 'properties') - */ - protected function addPropertyNesting($field, &$nestings, $property) - { - if (!isset($nestings[$property])) { - $nestings[$property] = array(); - } - $nestings[$property] = array_merge_recursive($nestings[$property], $this->getNestingsForType($field[$property])); + return $node; } /** @@ -432,7 +359,7 @@ class Configuration implements ConfigurationInterface ->end() ->scalarNode('compress')->end() ->scalarNode('compress_threshold')->end() - ->scalarNode('enabled')->end() + ->scalarNode('enabled')->defaultTrue()->end() ->end() ; @@ -474,4 +401,181 @@ class Configuration implements ConfigurationInterface return $node; } + + /** + * Returns the array node used for "_parent". + */ + protected function getParentNode() + { + $builder = new TreeBuilder(); + $node = $builder->root('_parent'); + + $node + ->children() + ->scalarNode('type')->end() + ->scalarNode('property')->defaultValue(null)->end() + ->scalarNode('identifier')->defaultValue('id')->end() + ->end() + ; + + return $node; + } + + /** + * Returns the array node used for "_all". + */ + protected function getAllNode() + { + $builder = new TreeBuilder(); + $node = $builder->root('_all'); + + $node + ->children() + ->scalarNode('enabled')->defaultValue(true)->end() + ->scalarNode('index_analyzer')->end() + ->scalarNode('search_analyzer')->end() + ->end() + ; + + return $node; + } + + /** + * Returns the array node used for "_timestamp". + */ + protected function getTimestampNode() + { + $builder = new TreeBuilder(); + $node = $builder->root('_timestamp'); + + $node + ->children() + ->scalarNode('enabled')->defaultValue(true)->end() + ->scalarNode('path')->end() + ->scalarNode('format')->end() + ->scalarNode('store')->end() + ->scalarNode('index')->end() + ->end() + ; + + return $node; + } + + /** + * Returns the array node used for "_ttl". + */ + protected function getTtlNode() + { + $builder = new TreeBuilder(); + $node = $builder->root('_ttl'); + + $node + ->children() + ->scalarNode('enabled')->defaultValue(true)->end() + ->scalarNode('default')->end() + ->scalarNode('store')->end() + ->scalarNode('index')->end() + ->end() + ; + + return $node; + } + + /** + * @return ArrayNodeDefinition|\Symfony\Component\Config\Definition\Builder\NodeDefinition + */ + protected function getPersistenceNode() + { + $builder = new TreeBuilder(); + $node = $builder->root('persistence'); + + $node + ->validate() + ->ifTrue(function ($v) { return isset($v['driver']) && 'propel' === $v['driver'] && isset($v['listener']); }) + ->thenInvalid('Propel doesn\'t support listeners') + ->ifTrue(function ($v) { return isset($v['driver']) && 'propel' === $v['driver'] && isset($v['repository']); }) + ->thenInvalid('Propel doesn\'t support the "repository" parameter') + ->end() + ->children() + ->scalarNode('driver') + ->validate() + ->ifNotInArray($this->supportedDrivers) + ->thenInvalid('The driver %s is not supported. Please choose one of '.json_encode($this->supportedDrivers)) + ->end() + ->end() + ->scalarNode('model')->end() + ->scalarNode('repository')->end() + ->scalarNode('identifier')->defaultValue('id')->end() + ->arrayNode('provider') + ->children() + ->scalarNode('batch_size')->defaultValue(100)->end() + ->scalarNode('clear_object_manager')->defaultTrue()->end() + ->scalarNode('debug_logging') + ->defaultValue($this->debug) + ->treatNullLike(true) + ->end() + ->scalarNode('query_builder_method')->defaultValue('createQueryBuilder')->end() + ->scalarNode('service')->end() + ->end() + ->end() + ->arrayNode('listener') + ->children() + ->scalarNode('insert')->defaultTrue()->end() + ->scalarNode('update')->defaultTrue()->end() + ->scalarNode('delete')->defaultTrue()->end() + ->scalarNode('flush')->defaultTrue()->end() + ->booleanNode('immediate')->defaultFalse()->end() + ->scalarNode('logger') + ->defaultFalse() + ->treatNullLike('fos_elastica.logger') + ->treatTrueLike('fos_elastica.logger') + ->end() + ->scalarNode('service')->end() + ->end() + ->end() + ->arrayNode('finder') + ->children() + ->scalarNode('service')->end() + ->end() + ->end() + ->arrayNode('elastica_to_model_transformer') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('hydrate')->defaultTrue()->end() + ->scalarNode('ignore_missing')->defaultFalse()->end() + ->scalarNode('query_builder_method')->defaultValue('createQueryBuilder')->end() + ->scalarNode('service')->end() + ->end() + ->end() + ->arrayNode('model_to_elastica_transformer') + ->addDefaultsIfNotSet() + ->children() + ->scalarNode('service')->end() + ->end() + ->end() + ->end(); + + return $node; + } + + /** + * @return ArrayNodeDefinition|\Symfony\Component\Config\Definition\Builder\NodeDefinition + */ + protected function getSerializerNode() + { + $builder = new TreeBuilder(); + $node = $builder->root('serializer'); + + $node + ->addDefaultsIfNotSet() + ->children() + ->arrayNode('groups') + ->treatNullLike(array()) + ->prototype('scalar')->end() + ->end() + ->scalarNode('version')->end() + ->end(); + + return $node; + } } diff --git a/DependencyInjection/FOSElasticaExtension.php b/DependencyInjection/FOSElasticaExtension.php index 0cb5c27..ec45e25 100644 --- a/DependencyInjection/FOSElasticaExtension.php +++ b/DependencyInjection/FOSElasticaExtension.php @@ -5,7 +5,6 @@ namespace FOS\ElasticaBundle\DependencyInjection; use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\DependencyInjection\Loader\XmlFileLoader; use Symfony\Component\DependencyInjection\ContainerBuilder; -use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\DefinitionDecorator; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\Config\FileLocator; @@ -13,20 +12,42 @@ use InvalidArgumentException; class FOSElasticaExtension extends Extension { - protected $indexConfigs = array(); - protected $typeFields = array(); - protected $loadedDrivers = array(); + /** + * Definition of elastica clients as configured by this extension. + * + * @var array + */ + private $clients = array(); + + /** + * An array of indexes as configured by the extension. + * + * @var array + */ + private $indexConfigs = array(); + + /** + * If we've encountered a type mapped to a specific persistence driver, it will be loaded + * here. + * + * @var array + */ + private $loadedDrivers = array(); public function load(array $configs, ContainerBuilder $container) { $configuration = $this->getConfiguration($configs, $container); - $config = $this->processConfiguration($configuration, $configs); + $config = $this->processConfiguration($configuration, $configs); $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); - $loader->load('config.xml'); if (empty($config['clients']) || empty($config['indexes'])) { - throw new InvalidArgumentException('You must define at least one client and one index'); + // No Clients or indexes are defined + return; + } + + foreach (array('config', 'index', 'persister', 'provider', 'source', 'transformer') as $basename) { + $loader->load(sprintf('%s.xml', $basename)); } if (empty($config['default_client'])) { @@ -39,229 +60,277 @@ class FOSElasticaExtension extends Extension $config['default_index'] = reset($keys); } - $clientIdsByName = $this->loadClients($config['clients'], $container); - $indexIdsByName = $this->loadIndexes($config['indexes'], $container, $clientIdsByName, $config['default_client']); - $indexRefsByName = array_map(function($id) { - return new Reference($id); - }, $indexIdsByName); + if (isset($config['serializer'])) { + $loader->load('serializer.xml'); - $this->loadIndexManager($indexRefsByName, $container); - $this->loadResetter($this->indexConfigs, $container); + $this->loadSerializer($config['serializer'], $container); + } + $this->loadClients($config['clients'], $container); $container->setAlias('fos_elastica.client', sprintf('fos_elastica.client.%s', $config['default_client'])); + + $this->loadIndexes($config['indexes'], $container); $container->setAlias('fos_elastica.index', sprintf('fos_elastica.index.%s', $config['default_index'])); + $container->getDefinition('fos_elastica.config_source.container')->replaceArgument(0, $this->indexConfigs); + + $this->loadIndexManager($container); + $this->createDefaultManagerAlias($config['default_manager'], $container); } + /** + * @param array $config + * @param ContainerBuilder $container + * + * @return Configuration + */ public function getConfiguration(array $config, ContainerBuilder $container) { - return new Configuration($config); + return new Configuration($container->getParameter('kernel.debug')); } /** * Loads the configured clients. * - * @param array $clients An array of clients configurations + * @param array $clients An array of clients configurations * @param ContainerBuilder $container A ContainerBuilder instance + * * @return array */ - protected function loadClients(array $clients, ContainerBuilder $container) + private function loadClients(array $clients, ContainerBuilder $container) { - $clientIds = array(); foreach ($clients as $name => $clientConfig) { - $clientDef = $container->getDefinition('fos_elastica.client'); + $clientId = sprintf('fos_elastica.client.%s', $name); + + $clientDef = new DefinitionDecorator('fos_elastica.client_prototype'); $clientDef->replaceArgument(0, $clientConfig); - $clientId = sprintf('fos_elastica.client.%s', $name); + $logger = $clientConfig['connections'][0]['logger']; + if (false !== $logger) { + $clientDef->addMethodCall('setLogger', array(new Reference($logger))); + } + $clientDef->addTag('fos_elastica.client'); $container->setDefinition($clientId, $clientDef); - $clientIds[$name] = $clientId; + $this->clients[$name] = array( + 'id' => $clientId, + 'reference' => new Reference($clientId), + ); } - - return $clientIds; } /** * Loads the configured indexes. * - * @param array $indexes An array of indexes configurations + * @param array $indexes An array of indexes configurations * @param ContainerBuilder $container A ContainerBuilder instance - * @param array $clientIdsByName - * @param $defaultClientName + * * @throws \InvalidArgumentException + * * @return array */ - protected function loadIndexes(array $indexes, ContainerBuilder $container, array $clientIdsByName, $defaultClientName) + private function loadIndexes(array $indexes, ContainerBuilder $container) { - $indexIds = array(); - foreach ($indexes as $name => $index) { - if (isset($index['client'])) { - $clientName = $index['client']; - if (!isset($clientIdsByName[$clientName])) { - throw new InvalidArgumentException(sprintf('The elastica client with name "%s" is not defined', $clientName)); - } - } else { - $clientName = $defaultClientName; - } + $indexableCallbacks = array(); - $clientId = $clientIdsByName[$clientName]; + foreach ($indexes as $name => $index) { $indexId = sprintf('fos_elastica.index.%s', $name); $indexName = isset($index['index_name']) ? $index['index_name'] : $name; - $indexDefArgs = array($indexName); - $indexDef = new Definition('%fos_elastica.index.class%', $indexDefArgs); - $indexDef->setFactoryService($clientId); - $indexDef->setFactoryMethod('getIndex'); + + $indexDef = new DefinitionDecorator('fos_elastica.index_prototype'); + $indexDef->replaceArgument(0, $indexName); + $indexDef->addTag('fos_elastica.index', array( + 'name' => $name, + )); + + if (isset($index['client'])) { + $client = $this->getClient($index['client']); + $indexDef->setFactoryService($client); + } + $container->setDefinition($indexId, $indexDef); - $typePrototypeConfig = isset($index['type_prototype']) ? $index['type_prototype'] : array(); - $indexIds[$name] = $indexId; + $reference = new Reference($indexId); + $this->indexConfigs[$name] = array( - 'index' => new Reference($indexId), - 'config' => array( - 'mappings' => array() - ) + 'elasticsearch_name' => $indexName, + 'reference' => $reference, + 'name' => $name, + 'settings' => $index['settings'], + 'type_prototype' => isset($index['type_prototype']) ? $index['type_prototype'] : array(), + 'use_alias' => $index['use_alias'], ); + if ($index['finder']) { - $this->loadIndexFinder($container, $name, $indexId); + $this->loadIndexFinder($container, $name, $reference); } - if (!empty($index['settings'])) { - $this->indexConfigs[$name]['config']['settings'] = $index['settings']; - } - $this->loadTypes(isset($index['types']) ? $index['types'] : array(), $container, $name, $indexId, $typePrototypeConfig); + + $this->loadTypes((array) $index['types'], $container, $this->indexConfigs[$name], $indexableCallbacks); } - return $indexIds; + $indexable = $container->getDefinition('fos_elastica.indexable'); + $indexable->replaceArgument(0, $indexableCallbacks); } /** * Loads the configured index finders. * * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container - * @param string $name The index name - * @param string $indexId The index service identifier + * @param string $name The index name + * @param Reference $index Reference to the related index + * * @return string */ - protected function loadIndexFinder(ContainerBuilder $container, $name, $indexId) + private function loadIndexFinder(ContainerBuilder $container, $name, Reference $index) { - $abstractTransformerId = 'fos_elastica.elastica_to_model_transformer.collection.prototype'; + /* Note: transformer services may conflict with "collection.index", if + * an index and type names were "collection" and an index, respectively. + */ $transformerId = sprintf('fos_elastica.elastica_to_model_transformer.collection.%s', $name); - $transformerDef = new DefinitionDecorator($abstractTransformerId); + $transformerDef = new DefinitionDecorator('fos_elastica.elastica_to_model_transformer.collection'); $container->setDefinition($transformerId, $transformerDef); - $abstractFinderId = 'fos_elastica.finder.prototype'; $finderId = sprintf('fos_elastica.finder.%s', $name); - $finderDef = new DefinitionDecorator($abstractFinderId); - $finderDef->replaceArgument(0, new Reference($indexId)); + $finderDef = new DefinitionDecorator('fos_elastica.finder'); + $finderDef->replaceArgument(0, $index); $finderDef->replaceArgument(1, new Reference($transformerId)); $container->setDefinition($finderId, $finderDef); - - return $finderId; } /** * Loads the configured types. * - * @param array $types An array of types configurations - * @param ContainerBuilder $container A ContainerBuilder instance - * @param $indexName - * @param $indexId - * @param array $typePrototypeConfig + * @param array $types + * @param ContainerBuilder $container + * @param array $indexConfig + * @param array $indexableCallbacks */ - protected function loadTypes(array $types, ContainerBuilder $container, $indexName, $indexId, array $typePrototypeConfig) + private function loadTypes(array $types, ContainerBuilder $container, array $indexConfig, array &$indexableCallbacks) { foreach ($types as $name => $type) { - $type = self::deepArrayUnion($typePrototypeConfig, $type); - $typeId = sprintf('%s.%s', $indexId, $name); - $typeDefArgs = array($name); - $typeDef = new Definition('%fos_elastica.type.class%', $typeDefArgs); - $typeDef->setFactoryService($indexId); - $typeDef->setFactoryMethod('getType'); + $indexName = $indexConfig['name']; + + $typeId = sprintf('%s.%s', $indexConfig['reference'], $name); + $typeDef = new DefinitionDecorator('fos_elastica.type_prototype'); + $typeDef->replaceArgument(0, $name); + $typeDef->setFactoryService($indexConfig['reference']); $container->setDefinition($typeId, $typeDef); - if (isset($type['_source'])) { - $this->indexConfigs[$indexName]['config']['mappings'][$name]['_source'] = $type['_source']; + + $typeConfig = array( + 'name' => $name, + 'mapping' => array(), // An array containing anything that gets sent directly to ElasticSearch + 'config' => array(), + ); + + foreach (array( + 'dynamic_templates', + 'properties', + '_all', + '_boost', + '_id', + '_parent', + '_routing', + '_source', + '_timestamp', + '_ttl', + ) as $field) { + if (isset($type[$field])) { + $typeConfig['mapping'][$field] = $type[$field]; + } } - if (isset($type['_boost'])) { - $this->indexConfigs[$indexName]['config']['mappings'][$name]['_boost'] = $type['_boost']; - } - if (isset($type['_routing'])) { - $this->indexConfigs[$indexName]['config']['mappings'][$name]['_routing'] = $type['_routing']; - } - if (isset($type['mappings'])) { - $this->indexConfigs[$indexName]['config']['mappings'][$name]['properties'] = $type['mappings']; - $typeName = sprintf('%s/%s', $indexName, $name); - $this->typeFields[$typeName] = $type['mappings']; + + foreach (array( + 'persistence', + 'serializer', + 'index_analyzer', + 'search_analyzer', + 'date_detection', + 'dynamic_date_formats', + 'numeric_detection', + ) as $field) { + $typeConfig['config'][$field] = array_key_exists($field, $type) ? + $type[$field] : + null; } + + $this->indexConfigs[$indexName]['types'][$name] = $typeConfig; + if (isset($type['persistence'])) { - $this->loadTypePersistenceIntegration($type['persistence'], $container, $typeDef, $indexName, $name); + $this->loadTypePersistenceIntegration($type['persistence'], $container, new Reference($typeId), $indexName, $name); + + $typeConfig['persistence'] = $type['persistence']; } - if (isset($type['index_analyzer'])) { - $this->indexConfigs[$indexName]['config']['mappings'][$name]['index_analyzer'] = $type['index_analyzer']; + + if (isset($type['indexable_callback'])) { + $indexableCallbacks[sprintf('%s/%s', $indexName, $name)] = $type['indexable_callback']; } - if (isset($type['search_analyzer'])) { - $this->indexConfigs[$indexName]['config']['mappings'][$name]['search_analyzer'] = $type['search_analyzer']; - } - if (isset($type['index'])) { - $this->indexConfigs[$indexName]['config']['mappings'][$name]['index'] = $type['index']; + + if ($container->hasDefinition('fos_elastica.serializer_callback_prototype')) { + $typeSerializerId = sprintf('%s.serializer.callback', $typeId); + $typeSerializerDef = new DefinitionDecorator('fos_elastica.serializer_callback_prototype'); + + if (isset($type['serializer']['groups'])) { + $typeSerializerDef->addMethodCall('setGroups', array($type['serializer']['groups'])); + } + if (isset($type['serializer']['version'])) { + $typeSerializerDef->addMethodCall('setVersion', array($type['serializer']['version'])); + } + + $typeDef->addMethodCall('setSerializer', array(array(new Reference($typeSerializerId), 'serialize'))); + $container->setDefinition($typeSerializerId, $typeSerializerDef); } } } /** - * Merges two arrays without reindexing numeric keys. + * Loads the optional provider and finder for a type. * - * @param array $array1 An array to merge - * @param array $array2 An array to merge - * - * @return array The merged array + * @param array $typeConfig + * @param ContainerBuilder $container + * @param Reference $typeRef + * @param string $indexName + * @param string $typeName */ - static protected function deepArrayUnion($array1, $array2) - { - foreach ($array2 as $key => $value) { - if (is_array($value) && isset($array1[$key]) && is_array($array1[$key])) { - $array1[$key] = self::deepArrayUnion($array1[$key], $value); - } else { - $array1[$key] = $value; - } - } - - return $array1; - } - - /** - * Loads the optional provider and finder for a type - * - * @param array $typeConfig - * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container - * @param \Symfony\Component\DependencyInjection\Definition $typeDef - * @param $indexName - * @param $typeName - */ - protected function loadTypePersistenceIntegration(array $typeConfig, ContainerBuilder $container, Definition $typeDef, $indexName, $typeName) + private function loadTypePersistenceIntegration(array $typeConfig, ContainerBuilder $container, Reference $typeRef, $indexName, $typeName) { $this->loadDriver($container, $typeConfig['driver']); $elasticaToModelTransformerId = $this->loadElasticaToModelTransformer($typeConfig, $container, $indexName, $typeName); $modelToElasticaTransformerId = $this->loadModelToElasticaTransformer($typeConfig, $container, $indexName, $typeName); - $objectPersisterId = $this->loadObjectPersister($typeConfig, $typeDef, $container, $indexName, $typeName, $modelToElasticaTransformerId); + $objectPersisterId = $this->loadObjectPersister($typeConfig, $typeRef, $container, $indexName, $typeName, $modelToElasticaTransformerId); if (isset($typeConfig['provider'])) { - $this->loadTypeProvider($typeConfig, $container, $objectPersisterId, $typeDef, $indexName, $typeName); + $this->loadTypeProvider($typeConfig, $container, $objectPersisterId, $indexName, $typeName); } if (isset($typeConfig['finder'])) { - $this->loadTypeFinder($typeConfig, $container, $elasticaToModelTransformerId, $typeDef, $indexName, $typeName); + $this->loadTypeFinder($typeConfig, $container, $elasticaToModelTransformerId, $typeRef, $indexName, $typeName); } if (isset($typeConfig['listener'])) { - $this->loadTypeListener($typeConfig, $container, $objectPersisterId, $typeDef, $indexName, $typeName); + $this->loadTypeListener($typeConfig, $container, $objectPersisterId, $indexName, $typeName); } } - protected function loadElasticaToModelTransformer(array $typeConfig, ContainerBuilder $container, $indexName, $typeName) + /** + * Creates and loads an ElasticaToModelTransformer. + * + * @param array $typeConfig + * @param ContainerBuilder $container + * @param string $indexName + * @param string $typeName + * + * @return string + */ + private function loadElasticaToModelTransformer(array $typeConfig, ContainerBuilder $container, $indexName, $typeName) { if (isset($typeConfig['elastica_to_model_transformer']['service'])) { return $typeConfig['elastica_to_model_transformer']['service']; } + + /* Note: transformer services may conflict with "prototype.driver", if + * the index and type names were "prototype" and a driver, respectively. + */ $abstractId = sprintf('fos_elastica.elastica_to_model_transformer.prototype.%s', $typeConfig['driver']); $serviceId = sprintf('fos_elastica.elastica_to_model_transformer.%s.%s', $indexName, $typeName); $serviceDef = new DefinitionDecorator($abstractId); @@ -271,103 +340,201 @@ class FOSElasticaExtension extends Extension $argPos = ('propel' === $typeConfig['driver']) ? 0 : 1; $serviceDef->replaceArgument($argPos, $typeConfig['model']); - $serviceDef->replaceArgument($argPos + 1, array( - 'identifier' => $typeConfig['identifier'], - 'hydrate' => $typeConfig['elastica_to_model_transformer']['hydrate'] - )); + $serviceDef->replaceArgument($argPos + 1, array_merge($typeConfig['elastica_to_model_transformer'], array( + 'identifier' => $typeConfig['identifier'], + ))); $container->setDefinition($serviceId, $serviceDef); return $serviceId; } - protected function loadModelToElasticaTransformer(array $typeConfig, ContainerBuilder $container, $indexName, $typeName) + /** + * Creates and loads a ModelToElasticaTransformer for an index/type. + * + * @param array $typeConfig + * @param ContainerBuilder $container + * @param string $indexName + * @param string $typeName + * + * @return string + */ + private function loadModelToElasticaTransformer(array $typeConfig, ContainerBuilder $container, $indexName, $typeName) { if (isset($typeConfig['model_to_elastica_transformer']['service'])) { return $typeConfig['model_to_elastica_transformer']['service']; } - $abstractId = sprintf('fos_elastica.model_to_elastica_transformer.prototype.auto'); + + $abstractId = $container->hasDefinition('fos_elastica.serializer_callback_prototype') ? + 'fos_elastica.model_to_elastica_identifier_transformer' : + 'fos_elastica.model_to_elastica_transformer'; + $serviceId = sprintf('fos_elastica.model_to_elastica_transformer.%s.%s', $indexName, $typeName); $serviceDef = new DefinitionDecorator($abstractId); $serviceDef->replaceArgument(0, array( - 'identifier' => $typeConfig['identifier'] + 'identifier' => $typeConfig['identifier'], )); $container->setDefinition($serviceId, $serviceDef); return $serviceId; } - protected function loadObjectPersister(array $typeConfig, Definition $typeDef, ContainerBuilder $container, $indexName, $typeName, $transformerId) + /** + * Creates and loads an object persister for a type. + * + * @param array $typeConfig + * @param Reference $typeRef + * @param ContainerBuilder $container + * @param string $indexName + * @param string $typeName + * @param string $transformerId + * + * @return string + */ + private function loadObjectPersister(array $typeConfig, Reference $typeRef, ContainerBuilder $container, $indexName, $typeName, $transformerId) { - $abstractId = sprintf('fos_elastica.object_persister.prototype'); + $arguments = array( + $typeRef, + new Reference($transformerId), + $typeConfig['model'], + ); + + if ($container->hasDefinition('fos_elastica.serializer_callback_prototype')) { + $abstractId = 'fos_elastica.object_serializer_persister'; + $callbackId = sprintf('%s.%s.serializer.callback', $this->indexConfigs[$indexName]['reference'], $typeName); + $arguments[] = array(new Reference($callbackId), 'serialize'); + } else { + $abstractId = 'fos_elastica.object_persister'; + $mapping = $this->indexConfigs[$indexName]['types'][$typeName]['mapping']; + $argument = $mapping['properties']; + if (isset($mapping['_parent'])) { + $argument['_parent'] = $mapping['_parent']; + } + $arguments[] = $argument; + } + $serviceId = sprintf('fos_elastica.object_persister.%s.%s', $indexName, $typeName); $serviceDef = new DefinitionDecorator($abstractId); - $serviceDef->replaceArgument(0, $typeDef); - $serviceDef->replaceArgument(1, new Reference($transformerId)); - $serviceDef->replaceArgument(2, $typeConfig['model']); - $serviceDef->replaceArgument(3, $this->typeFields[sprintf('%s/%s', $indexName, $typeName)]); + foreach ($arguments as $i => $argument) { + $serviceDef->replaceArgument($i, $argument); + } + $container->setDefinition($serviceId, $serviceDef); return $serviceId; } - protected function loadTypeProvider(array $typeConfig, ContainerBuilder $container, $objectPersisterId, $typeDef, $indexName, $typeName) + /** + * Loads a provider for a type. + * + * @param array $typeConfig + * @param ContainerBuilder $container + * @param string $objectPersisterId + * @param string $indexName + * @param string $typeName + * + * @return string + */ + private function loadTypeProvider(array $typeConfig, ContainerBuilder $container, $objectPersisterId, $indexName, $typeName) { if (isset($typeConfig['provider']['service'])) { return $typeConfig['provider']['service']; } + /* Note: provider services may conflict with "prototype.driver", if the + * index and type names were "prototype" and a driver, respectively. + */ $providerId = sprintf('fos_elastica.provider.%s.%s', $indexName, $typeName); - $providerDef = new DefinitionDecorator('fos_elastica.provider.prototype.' . $typeConfig['driver']); + $providerDef = new DefinitionDecorator('fos_elastica.provider.prototype.'.$typeConfig['driver']); $providerDef->addTag('fos_elastica.provider', array('index' => $indexName, 'type' => $typeName)); $providerDef->replaceArgument(0, new Reference($objectPersisterId)); - $providerDef->replaceArgument(1, $typeConfig['model']); + $providerDef->replaceArgument(2, $typeConfig['model']); // Propel provider can simply ignore Doctrine-specific options - $providerDef->replaceArgument(2, array_diff_key($typeConfig['provider'], array('service' => 1))); + $providerDef->replaceArgument(3, array_merge(array_diff_key($typeConfig['provider'], array('service' => 1)), array( + 'indexName' => $indexName, + 'typeName' => $typeName, + ))); $container->setDefinition($providerId, $providerDef); return $providerId; } - protected function loadTypeListener(array $typeConfig, ContainerBuilder $container, $objectPersisterId, $typeDef, $indexName, $typeName) + /** + * Loads doctrine listeners to handle indexing of new or updated objects. + * + * @param array $typeConfig + * @param ContainerBuilder $container + * @param string $objectPersisterId + * @param string $indexName + * @param string $typeName + * + * @return string + */ + private function loadTypeListener(array $typeConfig, ContainerBuilder $container, $objectPersisterId, $indexName, $typeName) { if (isset($typeConfig['listener']['service'])) { return $typeConfig['listener']['service']; } + + /* Note: listener services may conflict with "prototype.driver", if the + * index and type names were "prototype" and a driver, respectively. + */ $abstractListenerId = sprintf('fos_elastica.listener.prototype.%s', $typeConfig['driver']); $listenerId = sprintf('fos_elastica.listener.%s.%s', $indexName, $typeName); $listenerDef = new DefinitionDecorator($abstractListenerId); $listenerDef->replaceArgument(0, new Reference($objectPersisterId)); - $listenerDef->replaceArgument(1, $typeConfig['model']); - $listenerDef->replaceArgument(3, $typeConfig['identifier']); - $listenerDef->replaceArgument(2, $this->getDoctrineEvents($typeConfig)); + $listenerDef->replaceArgument(2, array( + 'identifier' => $typeConfig['identifier'], + 'indexName' => $indexName, + 'typeName' => $typeName, + )); + $listenerDef->replaceArgument(3, $typeConfig['listener']['logger'] ? + new Reference($typeConfig['listener']['logger']) : + null + ); + + $tagName = null; switch ($typeConfig['driver']) { - case 'orm': $listenerDef->addTag('doctrine.event_subscriber'); break; - case 'mongodb': $listenerDef->addTag('doctrine_mongodb.odm.event_subscriber'); break; + case 'orm': + $tagName = 'doctrine.event_listener'; + break; + case 'mongodb': + $tagName = 'doctrine_mongodb.odm.event_listener'; + break; } - if (isset($typeConfig['listener']['is_indexable_callback'])) { - $callback = $typeConfig['listener']['is_indexable_callback']; - if (is_array($callback)) { - list($class) = $callback + array(null); - if (is_string($class) && !class_exists($class)) { - $callback[0] = new Reference($class); - } + if (null !== $tagName) { + foreach ($this->getDoctrineEvents($typeConfig) as $event) { + $listenerDef->addTag($tagName, array('event' => $event)); } - - $listenerDef->addMethodCall('setIsIndexableCallback', array($callback)); } + $container->setDefinition($listenerId, $listenerDef); return $listenerId; } + /** + * Map Elastica to Doctrine events for the current driver. + */ private function getDoctrineEvents(array $typeConfig) { + switch ($typeConfig['driver']) { + case 'orm': + $eventsClass = '\Doctrine\ORM\Events'; + break; + case 'mongodb': + $eventsClass = '\Doctrine\ODM\MongoDB\Events'; + break; + default: + throw new InvalidArgumentException(sprintf('Cannot determine events for driver "%s"', $typeConfig['driver'])); + } + $events = array(); $eventMapping = array( - 'insert' => array('postPersist'), - 'update' => array('postUpdate'), - 'delete' => array('postRemove', 'preRemove') + 'insert' => array(constant($eventsClass.'::postPersist')), + 'update' => array(constant($eventsClass.'::postUpdate')), + 'delete' => array(constant($eventsClass.'::preRemove')), + 'flush' => array($typeConfig['listener']['immediate'] ? constant($eventsClass.'::preFlush') : constant($eventsClass.'::postFlush')), ); foreach ($eventMapping as $event => $doctrineEvents) { @@ -379,15 +546,26 @@ class FOSElasticaExtension extends Extension return $events; } - protected function loadTypeFinder(array $typeConfig, ContainerBuilder $container, $elasticaToModelId, $typeDef, $indexName, $typeName) + /** + * Loads a Type specific Finder. + * + * @param array $typeConfig + * @param ContainerBuilder $container + * @param string $elasticaToModelId + * @param Reference $typeRef + * @param string $indexName + * @param string $typeName + * + * @return string + */ + private function loadTypeFinder(array $typeConfig, ContainerBuilder $container, $elasticaToModelId, Reference $typeRef, $indexName, $typeName) { if (isset($typeConfig['finder']['service'])) { $finderId = $typeConfig['finder']['service']; } else { - $abstractFinderId = 'fos_elastica.finder.prototype'; $finderId = sprintf('fos_elastica.finder.%s.%s', $indexName, $typeName); - $finderDef = new DefinitionDecorator($abstractFinderId); - $finderDef->replaceArgument(0, $typeDef); + $finderDef = new DefinitionDecorator('fos_elastica.finder'); + $finderDef->replaceArgument(0, $typeRef); $finderDef->replaceArgument(1, new Reference($elasticaToModelId)); $container->setDefinition($finderId, $finderDef); } @@ -404,41 +582,63 @@ class FOSElasticaExtension extends Extension } /** - * Loads the index manager + * Loads the index manager. * - * @param array $indexRefsByName * @param ContainerBuilder $container **/ - protected function loadIndexManager(array $indexRefsByName, ContainerBuilder $container) + private function loadIndexManager(ContainerBuilder $container) { + $indexRefs = array_map(function ($index) { + return $index['reference']; + }, $this->indexConfigs); + $managerDef = $container->getDefinition('fos_elastica.index_manager'); - $managerDef->replaceArgument(0, $indexRefsByName); - $managerDef->replaceArgument(1, new Reference('fos_elastica.index')); + $managerDef->replaceArgument(0, $indexRefs); } /** - * Loads the resetter + * Makes sure a specific driver has been loaded. * - * @param array $indexConfigs - * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container + * @param ContainerBuilder $container + * @param string $driver */ - protected function loadResetter(array $indexConfigs, ContainerBuilder $container) - { - $resetterDef = $container->getDefinition('fos_elastica.resetter'); - $resetterDef->replaceArgument(0, $indexConfigs); - } - - protected function loadDriver(ContainerBuilder $container, $driver) + private function loadDriver(ContainerBuilder $container, $driver) { if (in_array($driver, $this->loadedDrivers)) { return; } + $loader = new XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config')); $loader->load($driver.'.xml'); $this->loadedDrivers[] = $driver; } - protected function createDefaultManagerAlias($defaultManager, ContainerBuilder $container) + /** + * Loads and configures the serializer prototype. + * + * @param array $config + * @param ContainerBuilder $container + */ + private function loadSerializer($config, ContainerBuilder $container) + { + $container->setAlias('fos_elastica.serializer', $config['serializer']); + + $serializer = $container->getDefinition('fos_elastica.serializer_callback_prototype'); + $serializer->setClass($config['callback_class']); + + $callbackClassImplementedInterfaces = class_implements($config['callback_class']); + if (isset($callbackClassImplementedInterfaces['Symfony\Component\DependencyInjection\ContainerAwareInterface'])) { + $serializer->addMethodCall('setContainer', array(new Reference('service_container'))); + } + } + + /** + * Creates a default manager alias for defined default manager or the first loaded driver. + * + * @param string $defaultManager + * @param ContainerBuilder $container + */ + private function createDefaultManagerAlias($defaultManager, ContainerBuilder $container) { if (0 == count($this->loadedDrivers)) { return; @@ -455,4 +655,21 @@ class FOSElasticaExtension extends Extension $container->setAlias('fos_elastica.manager', sprintf('fos_elastica.manager.%s', $defaultManagerService)); } + /** + * Returns a reference to a client given its configured name. + * + * @param string $clientName + * + * @return Reference + * + * @throws \InvalidArgumentException + */ + private function getClient($clientName) + { + if (!array_key_exists($clientName, $this->clients)) { + throw new InvalidArgumentException(sprintf('The elastica client with name "%s" is not defined', $clientName)); + } + + return $this->clients[$clientName]['reference']; + } } diff --git a/Doctrine/AbstractElasticaToModelTransformer.php b/Doctrine/AbstractElasticaToModelTransformer.php index 0b339fb..0263f42 100755 --- a/Doctrine/AbstractElasticaToModelTransformer.php +++ b/Doctrine/AbstractElasticaToModelTransformer.php @@ -2,48 +2,52 @@ namespace FOS\ElasticaBundle\Doctrine; +use Doctrine\Common\Persistence\ManagerRegistry; use FOS\ElasticaBundle\HybridResult; -use FOS\ElasticaBundle\Transformer\ElasticaToModelTransformerInterface; +use FOS\ElasticaBundle\Transformer\AbstractElasticaToModelTransformer as BaseTransformer; use FOS\ElasticaBundle\Transformer\HighlightableModelInterface; -use Symfony\Component\Form\Util\PropertyPath; /** * Maps Elastica documents with Doctrine objects * This mapper assumes an exact match between - * elastica documents ids and doctrine object ids + * elastica documents ids and doctrine object ids. */ -abstract class AbstractElasticaToModelTransformer implements ElasticaToModelTransformerInterface +abstract class AbstractElasticaToModelTransformer extends BaseTransformer { /** - * Manager registry + * Manager registry. + * + * @var ManagerRegistry */ protected $registry = null; /** - * Class of the model to map to the elastica documents + * Class of the model to map to the elastica documents. * * @var string */ protected $objectClass = null; /** - * Optional parameters + * Optional parameters. * * @var array */ protected $options = array( - 'hydrate' => true, - 'identifier' => 'id' + 'hydrate' => true, + 'identifier' => 'id', + 'ignore_missing' => false, + 'query_builder_method' => 'createQueryBuilder', ); /** - * Instantiates a new Mapper + * Instantiates a new Mapper. * - * @param object $registry + * @param ManagerRegistry $registry * @param string $objectClass - * @param array $options + * @param array $options */ - public function __construct($registry, $objectClass, array $options = array()) + public function __construct(ManagerRegistry $registry, $objectClass, array $options = array()) { $this->registry = $registry; $this->objectClass = $objectClass; @@ -62,10 +66,12 @@ abstract class AbstractElasticaToModelTransformer implements ElasticaToModelTran /** * Transforms an array of elastica objects into an array of - * model objects fetched from the doctrine repository + * model objects fetched from the doctrine repository. * * @param array $elasticaObjects of elastica objects + * * @throws \RuntimeException + * * @return array **/ public function transform(array $elasticaObjects) @@ -77,7 +83,7 @@ abstract class AbstractElasticaToModelTransformer implements ElasticaToModelTran } $objects = $this->findByIdentifiers($ids, $this->options['hydrate']); - if (count($objects) < count($elasticaObjects)) { + if (!$this->options['ignore_missing'] && count($objects) < count($elasticaObjects)) { throw new \RuntimeException('Cannot find corresponding Doctrine objects for all Elastica results.'); }; @@ -87,32 +93,34 @@ abstract class AbstractElasticaToModelTransformer implements ElasticaToModelTran } } - $identifierProperty = new PropertyPath($this->options['identifier']); - // sort objects in the order of ids $idPos = array_flip($ids); - usort($objects, function($a, $b) use ($idPos, $identifierProperty) - { - return $idPos[$identifierProperty->getValue($a)] > $idPos[$identifierProperty->getValue($b)]; - }); + $identifier = $this->options['identifier']; + usort($objects, $this->getSortingClosure($idPos, $identifier)); return $objects; } public function hybridTransform(array $elasticaObjects) { + $indexedElasticaResults = array(); + foreach ($elasticaObjects as $elasticaObject) { + $indexedElasticaResults[$elasticaObject->getId()] = $elasticaObject; + } + $objects = $this->transform($elasticaObjects); $result = array(); - for ($i = 0; $i < count($elasticaObjects); $i++) { - $result[] = new HybridResult($elasticaObjects[$i], $objects[$i]); + foreach ($objects as $object) { + $id = $this->propertyAccessor->getValue($object, $this->options['identifier']); + $result[] = new HybridResult($indexedElasticaResults[$id], $object); } return $result; } /** - * {@inheritdoc} + * {@inheritDoc} */ public function getIdentifierField() { @@ -120,11 +128,12 @@ abstract class AbstractElasticaToModelTransformer implements ElasticaToModelTran } /** - * Fetches objects by theses identifier values + * Fetches objects by theses identifier values. + * + * @param array $identifierValues ids values + * @param Boolean $hydrate whether or not to hydrate the objects, false returns arrays * - * @param array $identifierValues ids values - * @param Boolean $hydrate whether or not to hydrate the objects, false returns arrays * @return array of objects or arrays */ - protected abstract function findByIdentifiers(array $identifierValues, $hydrate); + abstract protected function findByIdentifiers(array $identifierValues, $hydrate); } diff --git a/Doctrine/AbstractListener.php b/Doctrine/AbstractListener.php deleted file mode 100644 index a7e8867..0000000 --- a/Doctrine/AbstractListener.php +++ /dev/null @@ -1,158 +0,0 @@ -objectPersister = $objectPersister; - $this->objectClass = $objectClass; - $this->events = $events; - $this->esIdentifierField = $esIdentifierField; - } - - /** - * @see Doctrine\Common\EventSubscriber::getSubscribedEvents() - */ - public function getSubscribedEvents() - { - return $this->events; - } - - /** - * Set the callback for determining object index eligibility. - * - * If callback is a string, it must be public method on the object class - * that expects no arguments and returns a boolean. Otherwise, the callback - * should expect the object for consideration as its only argument and - * return a boolean. - * - * @param callback $callback - * @throws \RuntimeException if the callback is not callable - */ - public function setIsIndexableCallback($callback) - { - if (is_string($callback)) { - if (!is_callable(array($this->objectClass, $callback))) { - throw new \RuntimeException(sprintf('Indexable callback %s::%s() is not callable.', $this->objectClass, $callback)); - } - } elseif (!is_callable($callback)) { - if (is_array($callback)) { - list($class, $method) = $callback + array(null, null); - if (is_object($class)) { - $class = get_class($class); - } - if ($class && $method) { - throw new \RuntimeException(sprintf('Indexable callback %s::%s() is not callable.', $class, $method)); - } - } - throw new \RuntimeException('Indexable callback is not callable.'); - } - - $this->isIndexableCallback = $callback; - } - - /** - * Return whether the object is indexable with respect to the callback. - * - * @param object $object - * @return boolean - */ - protected function isObjectIndexable($object) - { - if (!$this->isIndexableCallback) { - return true; - } - - return is_string($this->isIndexableCallback) - ? call_user_func(array($object, $this->isIndexableCallback)) - : call_user_func($this->isIndexableCallback, $object); - } - - /** - * Schedules the object for removal. - * - * This is usually called during the pre-remove event. - * - * @param object $object - * @param ObjectManager $objectManager - */ - protected function scheduleForRemoval($object, ObjectManager $objectManager) - { - $metadata = $objectManager->getClassMetadata($this->objectClass); - $esId = $metadata->getFieldValue($object, $this->esIdentifierField); - $this->scheduledForRemoval[spl_object_hash($object)] = $esId; - } - - /** - * Removes the object if it was scheduled for removal. - * - * This is usually called during the post-remove event. - * - * @param object $object - */ - protected function removeIfScheduled($object) - { - $objectHash = spl_object_hash($object); - if (isset($this->scheduledForRemoval[$objectHash])) { - $this->objectPersister->deleteById($this->scheduledForRemoval[$objectHash]); - unset($this->scheduledForRemoval[$objectHash]); - } - } -} diff --git a/Doctrine/AbstractProvider.php b/Doctrine/AbstractProvider.php index 43e8c20..ec198a8 100644 --- a/Doctrine/AbstractProvider.php +++ b/Doctrine/AbstractProvider.php @@ -3,68 +3,65 @@ namespace FOS\ElasticaBundle\Doctrine; use Doctrine\Common\Persistence\ManagerRegistry; +use Elastica\Exception\Bulk\ResponseException as BulkResponseException; use FOS\ElasticaBundle\Persister\ObjectPersisterInterface; use FOS\ElasticaBundle\Provider\AbstractProvider as BaseAbstractProvider; +use FOS\ElasticaBundle\Provider\IndexableInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; abstract class AbstractProvider extends BaseAbstractProvider { + /** + * @var SliceFetcherInterface + */ + private $sliceFetcher; + + /** + * @var ManagerRegistry + */ protected $managerRegistry; /** * Constructor. * * @param ObjectPersisterInterface $objectPersister + * @param IndexableInterface $indexable * @param string $objectClass - * @param array $options + * @param array $baseOptions * @param ManagerRegistry $managerRegistry + * @param SliceFetcherInterface $sliceFetcher */ - public function __construct(ObjectPersisterInterface $objectPersister, $objectClass, array $options, $managerRegistry) - { - parent::__construct($objectPersister, $objectClass, array_merge(array( - 'clear_object_manager' => true, - 'query_builder_method' => 'createQueryBuilder', - ), $options)); + public function __construct( + ObjectPersisterInterface $objectPersister, + IndexableInterface $indexable, + $objectClass, + array $baseOptions, + ManagerRegistry $managerRegistry, + SliceFetcherInterface $sliceFetcher = null + ) { + parent::__construct($objectPersister, $indexable, $objectClass, $baseOptions); $this->managerRegistry = $managerRegistry; - } - - /** - * @see FOS\ElasticaBundle\Provider\ProviderInterface::populate() - */ - public function populate(\Closure $loggerClosure = null) - { - $queryBuilder = $this->createQueryBuilder(); - $nbObjects = $this->countObjects($queryBuilder); - - for ($offset = 0; $offset < $nbObjects; $offset += $this->options['batch_size']) { - if ($loggerClosure) { - $stepStartTime = microtime(true); - } - $objects = $this->fetchSlice($queryBuilder, $this->options['batch_size'], $offset); - - $this->objectPersister->insertMany($objects); - - if ($this->options['clear_object_manager']) { - $this->managerRegistry->getManagerForClass($this->objectClass)->clear(); - } - - if ($loggerClosure) { - $stepNbObjects = count($objects); - $stepCount = $stepNbObjects + $offset; - $percentComplete = 100 * $stepCount / $nbObjects; - $objectsPerSecond = $stepNbObjects / (microtime(true) - $stepStartTime); - $loggerClosure(sprintf('%0.1f%% (%d/%d), %d objects/s', $percentComplete, $stepCount, $nbObjects, $objectsPerSecond)); - } - } + $this->sliceFetcher = $sliceFetcher; } /** * Counts objects that would be indexed using the query builder. * * @param object $queryBuilder + * * @return integer */ - protected abstract function countObjects($queryBuilder); + abstract protected function countObjects($queryBuilder); + + /** + * Creates the query builder, which will be used to fetch objects to index. + * + * @param string $method + * + * @return object + */ + abstract protected function createQueryBuilder($method); /** * Fetches a slice of objects using the query builder. @@ -72,14 +69,102 @@ abstract class AbstractProvider extends BaseAbstractProvider * @param object $queryBuilder * @param integer $limit * @param integer $offset + * * @return array */ - protected abstract function fetchSlice($queryBuilder, $limit, $offset); + abstract protected function fetchSlice($queryBuilder, $limit, $offset); /** - * Creates the query builder, which will be used to fetch objects to index. - * - * @return object + * {@inheritDoc} */ - protected abstract function createQueryBuilder(); + protected function doPopulate($options, \Closure $loggerClosure = null) + { + $manager = $this->managerRegistry->getManagerForClass($this->objectClass); + + $queryBuilder = $this->createQueryBuilder($options['query_builder_method']); + $nbObjects = $this->countObjects($queryBuilder); + $offset = $options['offset']; + + $objects = array(); + for (; $offset < $nbObjects; $offset += $options['batch_size']) { + try { + $objects = $this->getSlice($queryBuilder, $options['batch_size'], $offset, $objects); + $objects = $this->filterObjects($options, $objects); + + if (!empty($objects)) { + $this->objectPersister->insertMany($objects); + } + } catch (BulkResponseException $e) { + if (!$options['ignore_errors']) { + throw $e; + } + + if (null !== $loggerClosure) { + $loggerClosure( + $options['batch_size'], + $nbObjects, + sprintf('%s', $e->getMessage()) + ); + } + } + + if ($options['clear_object_manager']) { + $manager->clear(); + } + + usleep($options['sleep']); + + if (null !== $loggerClosure) { + $loggerClosure($options['batch_size'], $nbObjects); + } + } + } + + /** + * {@inheritDoc} + */ + protected function configureOptions() + { + parent::configureOptions(); + + $this->resolver->setDefaults(array( + 'clear_object_manager' => true, + 'debug_logging' => false, + 'ignore_errors' => false, + 'offset' => 0, + 'query_builder_method' => 'createQueryBuilder', + 'sleep' => 0 + )); + } + + /** + * If this Provider has a SliceFetcher defined, we use it instead of falling back to + * the fetchSlice methods defined in the ORM/MongoDB subclasses. + * + * @param $queryBuilder + * @param int $limit + * @param int $offset + * @param array $lastSlice + * + * @return array + */ + private function getSlice($queryBuilder, $limit, $offset, $lastSlice) + { + if (!$this->sliceFetcher) { + return $this->fetchSlice($queryBuilder, $limit, $offset); + } + + $manager = $this->managerRegistry->getManagerForClass($this->objectClass); + $identifierFieldNames = $manager + ->getClassMetadata($this->objectClass) + ->getIdentifierFieldNames(); + + return $this->sliceFetcher->fetch( + $queryBuilder, + $limit, + $offset, + $lastSlice, + $identifierFieldNames + ); + } } diff --git a/Doctrine/Listener.php b/Doctrine/Listener.php new file mode 100644 index 0000000..a1d3585 --- /dev/null +++ b/Doctrine/Listener.php @@ -0,0 +1,212 @@ +config = array_merge(array( + 'identifier' => 'id', + ), $config); + $this->indexable = $indexable; + $this->objectPersister = $objectPersister; + $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); + + if ($logger && $this->objectPersister instanceof ObjectPersister) { + $this->objectPersister->setLogger($logger); + } + } + + /** + * Looks for new objects that should be indexed. + * + * @param LifecycleEventArgs $eventArgs + */ + public function postPersist(LifecycleEventArgs $eventArgs) + { + $entity = $eventArgs->getObject(); + + if ($this->objectPersister->handlesObject($entity) && $this->isObjectIndexable($entity)) { + $this->scheduledForInsertion[] = $entity; + } + } + + /** + * Looks for objects being updated that should be indexed or removed from the index. + * + * @param LifecycleEventArgs $eventArgs + */ + public function postUpdate(LifecycleEventArgs $eventArgs) + { + $entity = $eventArgs->getObject(); + + if ($this->objectPersister->handlesObject($entity)) { + if ($this->isObjectIndexable($entity)) { + $this->scheduledForUpdate[] = $entity; + } else { + // Delete if no longer indexable + $this->scheduleForDeletion($entity); + } + } + } + + /** + * Delete objects preRemove instead of postRemove so that we have access to the id. Because this is called + * preRemove, first check that the entity is managed by Doctrine. + * + * @param LifecycleEventArgs $eventArgs + */ + public function preRemove(LifecycleEventArgs $eventArgs) + { + $entity = $eventArgs->getObject(); + + if ($this->objectPersister->handlesObject($entity)) { + $this->scheduleForDeletion($entity); + } + } + + /** + * Persist scheduled objects to ElasticSearch + * After persisting, clear the scheduled queue to prevent multiple data updates when using multiple flush calls. + */ + private function persistScheduled() + { + if (count($this->scheduledForInsertion)) { + $this->objectPersister->insertMany($this->scheduledForInsertion); + $this->scheduledForInsertion = array(); + } + if (count($this->scheduledForUpdate)) { + $this->objectPersister->replaceMany($this->scheduledForUpdate); + $this->scheduledForUpdate = array(); + } + if (count($this->scheduledForDeletion)) { + $this->objectPersister->deleteManyByIdentifiers($this->scheduledForDeletion); + $this->scheduledForDeletion = array(); + } + } + + /** + * Iterate through scheduled actions before flushing to emulate 2.x behavior. + * Note that the ElasticSearch index will fall out of sync with the source + * data in the event of a crash during flush. + * + * This method is only called in legacy configurations of the listener. + * + * @deprecated This method should only be called in applications that depend + * on the behaviour that entities are indexed regardless of if a + * flush is successful. + */ + public function preFlush() + { + $this->persistScheduled(); + } + + /** + * Iterating through scheduled actions *after* flushing ensures that the + * ElasticSearch index will be affected only if the query is successful. + */ + public function postFlush() + { + $this->persistScheduled(); + } + + /** + * Record the specified identifier to delete. Do not need to entire object. + * + * @param object $object + */ + private function scheduleForDeletion($object) + { + if ($identifierValue = $this->propertyAccessor->getValue($object, $this->config['identifier'])) { + $this->scheduledForDeletion[] = $identifierValue; + } + } + + /** + * Checks if the object is indexable or not. + * + * @param object $object + * + * @return bool + */ + private function isObjectIndexable($object) + { + return $this->indexable->isObjectIndexable( + $this->config['indexName'], + $this->config['typeName'], + $object + ); + } +} diff --git a/Doctrine/MongoDB/ElasticaToModelTransformer.php b/Doctrine/MongoDB/ElasticaToModelTransformer.php index 4c35a0c..23a8292 100644 --- a/Doctrine/MongoDB/ElasticaToModelTransformer.php +++ b/Doctrine/MongoDB/ElasticaToModelTransformer.php @@ -7,22 +7,24 @@ use FOS\ElasticaBundle\Doctrine\AbstractElasticaToModelTransformer; /** * Maps Elastica documents with Doctrine objects * This mapper assumes an exact match between - * elastica documents ids and doctrine object ids + * elastica documents ids and doctrine object ids. */ class ElasticaToModelTransformer extends AbstractElasticaToModelTransformer { /** - * Fetch objects for theses identifier values + * Fetch objects for theses identifier values. + * + * @param array $identifierValues ids values + * @param Boolean $hydrate whether or not to hydrate the objects, false returns arrays * - * @param array $identifierValues ids values - * @param Boolean $hydrate whether or not to hydrate the objects, false returns arrays * @return array of objects or arrays */ protected function findByIdentifiers(array $identifierValues, $hydrate) { return $this->registry ->getManagerForClass($this->objectClass) - ->createQueryBuilder($this->objectClass) + ->getRepository($this->objectClass) + ->{$this->options['query_builder_method']}($this->objectClass) ->field($this->options['identifier'])->in($identifierValues) ->hydrate($hydrate) ->getQuery() diff --git a/Doctrine/MongoDB/Listener.php b/Doctrine/MongoDB/Listener.php deleted file mode 100644 index 9fa3536..0000000 --- a/Doctrine/MongoDB/Listener.php +++ /dev/null @@ -1,50 +0,0 @@ -getDocument(); - - if ($document instanceof $this->objectClass && $this->isObjectIndexable($document)) { - $this->objectPersister->insertOne($document); - } - } - - public function postUpdate(LifecycleEventArgs $eventArgs) - { - $document = $eventArgs->getDocument(); - - if ($document instanceof $this->objectClass) { - if ($this->isObjectIndexable($document)) { - $this->objectPersister->replaceOne($document); - } else { - $this->scheduleForRemoval($document, $eventArgs->getDocumentManager()); - $this->removeIfScheduled($document); - } - } - } - - public function preRemove(LifecycleEventArgs $eventArgs) - { - $document = $eventArgs->getDocument(); - - if ($document instanceof $this->objectClass) { - $this->scheduleForRemoval($document, $eventArgs->getDocumentManager()); - } - } - - public function postRemove(LifecycleEventArgs $eventArgs) - { - $document = $eventArgs->getDocument(); - - if ($document instanceof $this->objectClass) { - $this->removeIfScheduled($document); - } - } -} diff --git a/Doctrine/MongoDB/Provider.php b/Doctrine/MongoDB/Provider.php index 16d9f76..e4b08c5 100644 --- a/Doctrine/MongoDB/Provider.php +++ b/Doctrine/MongoDB/Provider.php @@ -9,7 +9,42 @@ use FOS\ElasticaBundle\Exception\InvalidArgumentTypeException; class Provider extends AbstractProvider { /** - * @see FOS\ElasticaBundle\Doctrine\AbstractProvider::countObjects() + * Disables logging and returns the logger that was previously set. + * + * @return mixed + */ + protected function disableLogging() + { + $configuration = $this->managerRegistry + ->getManagerForClass($this->objectClass) + ->getConnection() + ->getConfiguration(); + + $logger = $configuration->getLoggerCallable(); + $configuration->setLoggerCallable(null); + + return $logger; + } + + /** + * Reenables the logger with the previously returned logger from disableLogging();. + * + * @param mixed $logger + * + * @return mixed + */ + protected function enableLogging($logger) + { + $configuration = $this->managerRegistry + ->getManagerForClass($this->objectClass) + ->getConnection() + ->getConfiguration(); + + $configuration->setLoggerCallable($logger); + } + + /** + * {@inheritDoc} */ protected function countObjects($queryBuilder) { @@ -23,7 +58,7 @@ class Provider extends AbstractProvider } /** - * @see FOS\ElasticaBundle\Doctrine\AbstractProvider::fetchSlice() + * {@inheritDoc} */ protected function fetchSlice($queryBuilder, $limit, $offset) { @@ -32,21 +67,21 @@ class Provider extends AbstractProvider } return $queryBuilder - ->limit($limit) ->skip($offset) + ->limit($limit) ->getQuery() ->execute() ->toArray(); } /** - * @see FOS\ElasticaBundle\Doctrine\AbstractProvider::createQueryBuilder() + * {@inheritDoc} */ - protected function createQueryBuilder() + protected function createQueryBuilder($method) { return $this->managerRegistry ->getManagerForClass($this->objectClass) ->getRepository($this->objectClass) - ->{$this->options['query_builder_method']}(); + ->{$method}(); } } diff --git a/Doctrine/MongoDB/SliceFetcher.php b/Doctrine/MongoDB/SliceFetcher.php new file mode 100644 index 0000000..4723da6 --- /dev/null +++ b/Doctrine/MongoDB/SliceFetcher.php @@ -0,0 +1,44 @@ + + */ +class SliceFetcher implements SliceFetcherInterface +{ + /** + * {@inheritdoc} + */ + public function fetch($queryBuilder, $limit, $offset, array $previousSlice, array $identifierFieldNames) + { + if (!$queryBuilder instanceof Builder) { + throw new InvalidArgumentTypeException($queryBuilder, 'Doctrine\ODM\MongoDB\Query\Builder'); + } + + $lastObject = array_pop($previousSlice); + + if ($lastObject) { + $queryBuilder + ->field('_id')->gt($lastObject->getId()) + ->skip(0) + ; + } else { + $queryBuilder->skip($offset); + } + + return $queryBuilder + ->limit($limit) + ->sort(array('_id' => 'asc')) + ->getQuery() + ->execute() + ->toArray() + ; + } +} diff --git a/Doctrine/ORM/ElasticaToModelTransformer.php b/Doctrine/ORM/ElasticaToModelTransformer.php index 0a889a3..21d8640 100644 --- a/Doctrine/ORM/ElasticaToModelTransformer.php +++ b/Doctrine/ORM/ElasticaToModelTransformer.php @@ -8,15 +8,18 @@ use Doctrine\ORM\Query; /** * Maps Elastica documents with Doctrine objects * This mapper assumes an exact match between - * elastica documents ids and doctrine object ids + * elastica documents ids and doctrine object ids. */ class ElasticaToModelTransformer extends AbstractElasticaToModelTransformer { + const ENTITY_ALIAS = 'o'; + /** - * Fetch objects for theses identifier values + * Fetch objects for theses identifier values. + * + * @param array $identifierValues ids values + * @param Boolean $hydrate whether or not to hydrate the objects, false returns arrays * - * @param array $identifierValues ids values - * @param Boolean $hydrate whether or not to hydrate the objects, false returns arrays * @return array of objects or arrays */ protected function findByIdentifiers(array $identifierValues, $hydrate) @@ -25,14 +28,25 @@ class ElasticaToModelTransformer extends AbstractElasticaToModelTransformer return array(); } $hydrationMode = $hydrate ? Query::HYDRATE_OBJECT : Query::HYDRATE_ARRAY; - $qb = $this->registry - ->getManagerForClass($this->objectClass) - ->getRepository($this->objectClass) - ->createQueryBuilder('o'); - /* @var $qb \Doctrine\ORM\QueryBuilder */ - $qb->where($qb->expr()->in('o.'.$this->options['identifier'], ':values')) + + $qb = $this->getEntityQueryBuilder(); + $qb->andWhere($qb->expr()->in(static::ENTITY_ALIAS.'.'.$this->options['identifier'], ':values')) ->setParameter('values', $identifierValues); return $qb->getQuery()->setHydrationMode($hydrationMode)->execute(); } + + /** + * Retrieves a query builder to be used for querying by identifiers. + * + * @return \Doctrine\ORM\QueryBuilder + */ + protected function getEntityQueryBuilder() + { + $repository = $this->registry + ->getManagerForClass($this->objectClass) + ->getRepository($this->objectClass); + + return $repository->{$this->options['query_builder_method']}(static::ENTITY_ALIAS); + } } diff --git a/Doctrine/ORM/Listener.php b/Doctrine/ORM/Listener.php deleted file mode 100644 index 790ddb8..0000000 --- a/Doctrine/ORM/Listener.php +++ /dev/null @@ -1,50 +0,0 @@ -getEntity(); - - if ($entity instanceof $this->objectClass && $this->isObjectIndexable($entity)) { - $this->objectPersister->insertOne($entity); - } - } - - public function postUpdate(LifecycleEventArgs $eventArgs) - { - $entity = $eventArgs->getEntity(); - - if ($entity instanceof $this->objectClass) { - if ($this->isObjectIndexable($entity)) { - $this->objectPersister->replaceOne($entity); - } else { - $this->scheduleForRemoval($entity, $eventArgs->getEntityManager()); - $this->removeIfScheduled($entity); - } - } - } - - public function preRemove(LifecycleEventArgs $eventArgs) - { - $entity = $eventArgs->getEntity(); - - if ($entity instanceof $this->objectClass) { - $this->scheduleForRemoval($entity, $eventArgs->getEntityManager()); - } - } - - public function postRemove(LifecycleEventArgs $eventArgs) - { - $entity = $eventArgs->getEntity(); - - if ($entity instanceof $this->objectClass) { - $this->removeIfScheduled($entity); - } - } -} diff --git a/Doctrine/ORM/Provider.php b/Doctrine/ORM/Provider.php index 0f130fc..85b5279 100644 --- a/Doctrine/ORM/Provider.php +++ b/Doctrine/ORM/Provider.php @@ -8,8 +8,45 @@ use FOS\ElasticaBundle\Exception\InvalidArgumentTypeException; class Provider extends AbstractProvider { + const ENTITY_ALIAS = 'a'; + /** - * @see FOS\ElasticaBundle\Doctrine\AbstractProvider::countObjects() + * Disables logging and returns the logger that was previously set. + * + * @return mixed + */ + protected function disableLogging() + { + $configuration = $this->managerRegistry + ->getManagerForClass($this->objectClass) + ->getConnection() + ->getConfiguration(); + + $logger = $configuration->getSQLLogger(); + $configuration->setSQLLogger(null); + + return $logger; + } + + /** + * Reenables the logger with the previously returned logger from disableLogging();. + * + * @param mixed $logger + * + * @return mixed + */ + protected function enableLogging($logger) + { + $configuration = $this->managerRegistry + ->getManagerForClass($this->objectClass) + ->getConnection() + ->getConfiguration(); + + $configuration->setSQLLogger($logger); + } + + /** + * {@inheritDoc} */ protected function countObjects($queryBuilder) { @@ -32,7 +69,9 @@ class Provider extends AbstractProvider } /** - * @see FOS\ElasticaBundle\Doctrine\AbstractProvider::fetchSlice() + * This method should remain in sync with SliceFetcher::fetch until it is deprecated and removed. + * + * {@inheritDoc} */ protected function fetchSlice($queryBuilder, $limit, $offset) { @@ -40,6 +79,24 @@ class Provider extends AbstractProvider throw new InvalidArgumentTypeException($queryBuilder, 'Doctrine\ORM\QueryBuilder'); } + /* + * An orderBy DQL part is required to avoid fetching the same row twice. + * @see http://stackoverflow.com/questions/6314879/does-limit-offset-length-require-order-by-for-pagination + * @see http://www.postgresql.org/docs/current/static/queries-limit.html + * @see http://www.sqlite.org/lang_select.html#orderby + */ + $orderBy = $queryBuilder->getDQLPart('orderBy'); + if (empty($orderBy)) { + $rootAliases = $queryBuilder->getRootAliases(); + $identifierFieldNames = $this->managerRegistry + ->getManagerForClass($this->objectClass) + ->getClassMetadata($this->objectClass) + ->getIdentifierFieldNames(); + foreach ($identifierFieldNames as $fieldName) { + $queryBuilder->addOrderBy($rootAliases[0].'.'.$fieldName); + } + } + return $queryBuilder ->setFirstResult($offset) ->setMaxResults($limit) @@ -48,14 +105,14 @@ class Provider extends AbstractProvider } /** - * @see FOS\ElasticaBundle\Doctrine\AbstractProvider::createQueryBuilder() + * {@inheritDoc} */ - protected function createQueryBuilder() + protected function createQueryBuilder($method) { return $this->managerRegistry ->getManagerForClass($this->objectClass) ->getRepository($this->objectClass) // ORM query builders require an alias argument - ->{$this->options['query_builder_method']}('a'); + ->{$method}(static::ENTITY_ALIAS); } } diff --git a/Doctrine/ORM/SliceFetcher.php b/Doctrine/ORM/SliceFetcher.php new file mode 100644 index 0000000..ac6c816 --- /dev/null +++ b/Doctrine/ORM/SliceFetcher.php @@ -0,0 +1,50 @@ + + */ +class SliceFetcher implements SliceFetcherInterface +{ + /** + * This method should remain in sync with Provider::fetchSlice until that method is deprecated and + * removed. + * + * {@inheritdoc} + */ + public function fetch($queryBuilder, $limit, $offset, array $previousSlice, array $identifierFieldNames) + { + if (!$queryBuilder instanceof QueryBuilder) { + throw new InvalidArgumentTypeException($queryBuilder, 'Doctrine\ORM\QueryBuilder'); + } + + /* + * An orderBy DQL part is required to avoid feching the same row twice. + * @see http://stackoverflow.com/questions/6314879/does-limit-offset-length-require-order-by-for-pagination + * @see http://www.postgresql.org/docs/current/static/queries-limit.html + * @see http://www.sqlite.org/lang_select.html#orderby + */ + $orderBy = $queryBuilder->getDQLPart('orderBy'); + if (empty($orderBy)) { + $rootAliases = $queryBuilder->getRootAliases(); + + foreach ($identifierFieldNames as $fieldName) { + $queryBuilder->addOrderBy($rootAliases[0].'.'.$fieldName); + } + } + + return $queryBuilder + ->setFirstResult($offset) + ->setMaxResults($limit) + ->getQuery() + ->getResult() + ; + } +} diff --git a/Doctrine/RepositoryManager.php b/Doctrine/RepositoryManager.php index 8224ffb..0d20f64 100644 --- a/Doctrine/RepositoryManager.php +++ b/Doctrine/RepositoryManager.php @@ -25,20 +25,19 @@ class RepositoryManager extends BaseManager } /** - * Return repository for entity + * Return repository for entity. * * Returns custom repository if one specified otherwise - * returns a basic respository. + * returns a basic repository. */ public function getRepository($entityName) { $realEntityName = $entityName; if (strpos($entityName, ':') !== false) { list($namespaceAlias, $simpleClassName) = explode(':', $entityName); - $realEntityName = $this->managerRegistry->getAliasNamespace($namespaceAlias) . '\\' . $simpleClassName; + $realEntityName = $this->managerRegistry->getAliasNamespace($namespaceAlias).'\\'.$simpleClassName; } return parent::getRepository($realEntityName); } - } diff --git a/Doctrine/SliceFetcherInterface.php b/Doctrine/SliceFetcherInterface.php new file mode 100644 index 0000000..a028abf --- /dev/null +++ b/Doctrine/SliceFetcherInterface.php @@ -0,0 +1,24 @@ + + */ +interface SliceFetcherInterface +{ + /** + * Fetches a slice of objects using the query builder. + * + * @param object $queryBuilder + * @param integer $limit + * @param integer $offset + * @param array $previousSlice + * @param array $identifierFieldNames + * + * @return array + */ + public function fetch($queryBuilder, $limit, $offset, array $previousSlice, array $identifierFieldNames); +} diff --git a/DynamicIndex.php b/DynamicIndex.php new file mode 100644 index 0000000..484a0d6 --- /dev/null +++ b/DynamicIndex.php @@ -0,0 +1,12 @@ + + */ +class Client extends BaseClient +{ + /** + * Stores created indexes to avoid recreation. + * + * @var array + */ + private $indexCache = array(); + + /** + * Symfony's debugging Stopwatch. + * + * @var Stopwatch|null + */ + private $stopwatch; + + /** + * @param string $path + * @param string $method + * @param array $data + * @param array $query + * + * @return \Elastica\Response + */ + public function request($path, $method = Request::GET, $data = array(), array $query = array()) + { + if ($this->stopwatch) { + $this->stopwatch->start('es_request', 'fos_elastica'); + } + + $start = microtime(true); + $response = parent::request($path, $method, $data, $query); + + $this->logQuery($path, $method, $data, $query, $start); + + if ($this->stopwatch) { + $this->stopwatch->stop('es_request'); + } + + return $response; + } + + public function getIndex($name) + { + if (isset($this->indexCache[$name])) { + return $this->indexCache[$name]; + } + + return $this->indexCache[$name] = new Index($this, $name); + } + + /** + * Sets a stopwatch instance for debugging purposes. + * + * @param Stopwatch $stopwatch + */ + public function setStopwatch(Stopwatch $stopwatch = null) + { + $this->stopwatch = $stopwatch; + } + + /** + * Log the query if we have an instance of ElasticaLogger. + * + * @param string $path + * @param string $method + * @param array $data + * @param array $query + * @param int $start + */ + private function logQuery($path, $method, $data, array $query, $start) + { + if (!$this->_logger or !$this->_logger instanceof ElasticaLogger) { + return; + } + + $time = microtime(true) - $start; + $connection = $this->getLastRequest()->getConnection(); + + $connection_array = array( + 'host' => $connection->getHost(), + 'port' => $connection->getPort(), + 'transport' => $connection->getTransport(), + 'headers' => $connection->hasConfig('headers') ? $connection->getConfig('headers') : array(), + ); + + $this->_logger->logQuery($path, $method, $data, $time, $connection_array, $query); + } +} diff --git a/Elastica/Index.php b/Elastica/Index.php new file mode 100644 index 0000000..ad3bd4d --- /dev/null +++ b/Elastica/Index.php @@ -0,0 +1,59 @@ + + */ +class Index extends BaseIndex +{ + private $originalName; + + /** + * Stores created types to avoid recreation. + * + * @var array + */ + private $typeCache = array(); + + /** + * Returns the original name of the index if the index has been renamed for reindexing + * or realiasing purposes. + * + * @return string + */ + public function getOriginalName() + { + return $this->originalName ?: $this->_name; + } + + /** + * @param string $type + */ + public function getType($type) + { + if (isset($this->typeCache[$type])) { + return $this->typeCache[$type]; + } + + return $this->typeCache[$type] = parent::getType($type); + } + + /** + * Reassign index name. + * + * While it's technically a regular setter for name property, it's specifically named overrideName, but not setName + * since it's used for a very specific case and normally should not be used + * + * @param string $name Index name + */ + public function overrideName($name) + { + $this->originalName = $this->_name; + $this->_name = $name; + } +} diff --git a/Event/IndexEvent.php b/Event/IndexEvent.php new file mode 100644 index 0000000..ed71d78 --- /dev/null +++ b/Event/IndexEvent.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Event; + +use Symfony\Component\EventDispatcher\Event; + +class IndexEvent extends Event +{ + /** + * @var string + */ + private $index; + + /** + * @param string $index + */ + public function __construct($index) + { + $this->index = $index; + } + + /** + * @return string + */ + public function getIndex() + { + return $this->index; + } +} diff --git a/Event/IndexPopulateEvent.php b/Event/IndexPopulateEvent.php new file mode 100644 index 0000000..b35105a --- /dev/null +++ b/Event/IndexPopulateEvent.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Event; + +/** + * Index Populate Event. + * + * @author Oleg Andreyev + */ +class IndexPopulateEvent extends IndexEvent +{ + const PRE_INDEX_POPULATE = 'elastica.index.index_pre_populate'; + const POST_INDEX_POPULATE = 'elastica.index.index_post_populate'; + + /** + * @var bool + */ + private $reset; + + /** + * @var array + */ + private $options; + + /** + * @param string $index + * @param boolean $reset + * @param array $options + */ + public function __construct($index, $reset, $options) + { + parent::__construct($index); + + $this->reset = $reset; + $this->options = $options; + } + + /** + * @return boolean + */ + public function isReset() + { + return $this->reset; + } + + /** + * @return array + */ + public function getOptions() + { + return $this->options; + } + + /** + * @param boolean $reset + */ + public function setReset($reset) + { + $this->reset = $reset; + } +} diff --git a/Event/IndexResetEvent.php b/Event/IndexResetEvent.php new file mode 100644 index 0000000..871915a --- /dev/null +++ b/Event/IndexResetEvent.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Event; + +/** + * Index ResetEvent. + * + * @author Oleg Andreyev + */ +class IndexResetEvent extends IndexEvent +{ + const PRE_INDEX_RESET = 'elastica.index.pre_reset'; + const POST_INDEX_RESET = 'elastica.index.post_reset'; + + /** + * @var bool + */ + private $force; + + /** + * @var bool + */ + private $populating; + + /** + * @param string $index + * @param bool $populating + * @param bool $force + */ + public function __construct($index, $populating, $force) + { + parent::__construct($index); + + $this->force = $force; + $this->populating = $populating; + } + + /** + * @return boolean + */ + public function isForce() + { + return $this->force; + } + + /** + * @return boolean + */ + public function isPopulating() + { + return $this->populating; + } + + /** + * @param boolean $force + */ + public function setForce($force) + { + $this->force = $force; + } +} diff --git a/Event/TransformEvent.php b/Event/TransformEvent.php new file mode 100644 index 0000000..4f6871f --- /dev/null +++ b/Event/TransformEvent.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Event; + +use Symfony\Component\EventDispatcher\Event; + +class TransformEvent extends Event +{ + const POST_TRANSFORM = 'fos_elastica.post_transform'; + + /** + * @var mixed + */ + private $document; + + /** + * @var array + */ + private $fields; + + /** + * @var mixed + */ + private $object; + + /** + * @param mixed $document + * @param array $fields + * @param mixed $object + */ + public function __construct($document, array $fields, $object) + { + $this->document = $document; + $this->fields = $fields; + $this->object = $object; + } + + /** + * @return mixed + */ + public function getDocument() + { + return $this->document; + } + + /** + * @return array + */ + public function getFields() + { + return $this->fields; + } + + /** + * @return mixed + */ + public function getObject() + { + return $this->object; + } + + /** + * @param mixed $document + */ + public function setDocument($document) + { + $this->document = $document; + } +} diff --git a/Event/TypePopulateEvent.php b/Event/TypePopulateEvent.php new file mode 100644 index 0000000..dd744f5 --- /dev/null +++ b/Event/TypePopulateEvent.php @@ -0,0 +1,51 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Event; + +use Symfony\Component\EventDispatcher\Event; + +/** + * Type Populate Event. + * + * @author Oleg Andreyev + */ +class TypePopulateEvent extends IndexPopulateEvent +{ + const PRE_TYPE_POPULATE = 'elastica.index.type_pre_populate'; + const POST_TYPE_POPULATE = 'elastica.index.type_post_populate'; + + /** + * @var string + */ + private $type; + + /** + * @param string $index + * @param string $type + * @param bool $reset + * @param array $options + */ + public function __construct($index, $type, $reset, $options) + { + parent::__construct($index, $reset, $options); + + $this->type = $type; + } + + /** + * @return string + */ + public function getType() + { + return $this->type; + } +} diff --git a/Event/TypeResetEvent.php b/Event/TypeResetEvent.php new file mode 100644 index 0000000..98fa2f4 --- /dev/null +++ b/Event/TypeResetEvent.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Event; + +use Symfony\Component\EventDispatcher\Event; + +/** + * Type ResetEvent. + * + * @author Oleg Andreyev + */ +class TypeResetEvent extends IndexEvent +{ + const PRE_TYPE_RESET = 'elastica.index.type_pre_reset'; + const POST_TYPE_RESET = 'elastica.index.type_post_reset'; + + /** + * @var string + */ + private $type; + + /** + * @param string $index + * @param string $type + */ + public function __construct($index, $type) + { + parent::__construct($index); + + $this->type = $type; + } + + /** + * @return string + */ + public function getType() + { + return $this->type; + } +} diff --git a/Exception/AliasIsIndexException.php b/Exception/AliasIsIndexException.php new file mode 100644 index 0000000..9af6ee3 --- /dev/null +++ b/Exception/AliasIsIndexException.php @@ -0,0 +1,11 @@ +addCompilerPass(new ConfigSourcePass()); + $container->addCompilerPass(new IndexPass()); $container->addCompilerPass(new RegisterProvidersPass(), PassConfig::TYPE_BEFORE_REMOVING); $container->addCompilerPass(new TransformerPass()); } diff --git a/Finder/FinderInterface.php b/Finder/FinderInterface.php index 2cc0838..86dbf86 100644 --- a/Finder/FinderInterface.php +++ b/Finder/FinderInterface.php @@ -5,11 +5,13 @@ namespace FOS\ElasticaBundle\Finder; interface FinderInterface { /** - * Searches for query results within a given limit + * Searches for query results within a given limit. + * + * @param mixed $query Can be a string, an array or an \Elastica\Query object + * @param int $limit How many results to get + * @param array $options * - * @param mixed $query Can be a string, an array or an Elastica_Query object - * @param int $limit How many results to get * @return array results */ - function find($query, $limit = null); + public function find($query, $limit = null, $options = array()); } diff --git a/Finder/PaginatedFinderInterface.php b/Finder/PaginatedFinderInterface.php index e26a048..1fc7a48 100644 --- a/Finder/PaginatedFinderInterface.php +++ b/Finder/PaginatedFinderInterface.php @@ -4,23 +4,27 @@ namespace FOS\ElasticaBundle\Finder; use FOS\ElasticaBundle\Paginator\PaginatorAdapterInterface; use Pagerfanta\Pagerfanta; -use Elastica_Query; +use Elastica\Query; interface PaginatedFinderInterface extends FinderInterface { /** - * Searches for query results and returns them wrapped in a paginator + * Searches for query results and returns them wrapped in a paginator. + * + * @param mixed $query Can be a string, an array or an \Elastica\Query object + * @param array $options * - * @param mixed $query Can be a string, an array or an Elastica_Query object * @return Pagerfanta paginated results */ - function findPaginated($query); + public function findPaginated($query, $options = array()); /** - * Creates a paginator adapter for this query + * Creates a paginator adapter for this query. * * @param mixed $query + * @param array $options + * * @return PaginatorAdapterInterface */ - function createPaginatorAdapter($query); + public function createPaginatorAdapter($query, $options = array()); } diff --git a/Finder/TransformedFinder.php b/Finder/TransformedFinder.php index e1eade8..44f6d2f 100644 --- a/Finder/TransformedFinder.php +++ b/Finder/TransformedFinder.php @@ -2,76 +2,98 @@ namespace FOS\ElasticaBundle\Finder; -use FOS\ElasticaBundle\Finder\PaginatedFinderInterface; +use Elastica\Document; use FOS\ElasticaBundle\Transformer\ElasticaToModelTransformerInterface; use FOS\ElasticaBundle\Paginator\TransformedPaginatorAdapter; use FOS\ElasticaBundle\Paginator\FantaPaginatorAdapter; use Pagerfanta\Pagerfanta; -use Elastica_Searchable; -use Elastica_Query; +use Elastica\SearchableInterface; +use Elastica\Query; /** - * Finds elastica documents and map them to persisted objects + * Finds elastica documents and map them to persisted objects. */ class TransformedFinder implements PaginatedFinderInterface { protected $searchable; protected $transformer; - public function __construct(Elastica_Searchable $searchable, ElasticaToModelTransformerInterface $transformer) + public function __construct(SearchableInterface $searchable, ElasticaToModelTransformerInterface $transformer) { $this->searchable = $searchable; $this->transformer = $transformer; } /** - * Search for a query string + * Search for a query string. * - * @param string $query + * @param string $query * @param integer $limit + * @param array $options + * * @return array of model objects **/ - public function find($query, $limit = null) + public function find($query, $limit = null, $options = array()) { - $results = $this->search($query, $limit); + $results = $this->search($query, $limit, $options); return $this->transformer->transform($results); } - public function findHybrid($query, $limit = null) + public function findHybrid($query, $limit = null, $options = array()) { - $results = $this->search($query, $limit); + $results = $this->search($query, $limit, $options); return $this->transformer->hybridTransform($results); } + /** + * Find documents similar to one with passed id. + * + * @param integer $id + * @param array $params + * @param array $query + * + * @return array of model objects + **/ + public function moreLikeThis($id, $params = array(), $query = array()) + { + $doc = new Document($id); + $results = $this->searchable->moreLikeThis($doc, $params, $query)->getResults(); + + return $this->transformer->transform($results); + } + /** * @param $query * @param null|int $limit + * @param array $options + * * @return array */ - protected function search($query, $limit = null) + protected function search($query, $limit = null, $options = array()) { - $queryObject = Elastica_Query::create($query); + $queryObject = Query::create($query); if (null !== $limit) { - $queryObject->setLimit($limit); + $queryObject->setSize($limit); } - $results = $this->searchable->search($queryObject)->getResults(); + $results = $this->searchable->search($queryObject, $options)->getResults(); return $results; } - /** - * Gets a paginator wrapping the result of a search + * Gets a paginator wrapping the result of a search. * * @param string $query + * @param array $options + * * @return Pagerfanta */ - public function findPaginated($query) + public function findPaginated($query, $options = array()) { - $queryObject = Elastica_Query::create($query); - $paginatorAdapter = $this->createPaginatorAdapter($queryObject); + $queryObject = Query::create($query); + $paginatorAdapter = $this->createPaginatorAdapter($queryObject, $options); return new Pagerfanta(new FantaPaginatorAdapter($paginatorAdapter)); } @@ -79,9 +101,10 @@ class TransformedFinder implements PaginatedFinderInterface /** * {@inheritdoc} */ - public function createPaginatorAdapter($query) + public function createPaginatorAdapter($query, $options = array()) { - $query = Elastica_Query::create($query); - return new TransformedPaginatorAdapter($this->searchable, $query, $this->transformer); + $query = Query::create($query); + + return new TransformedPaginatorAdapter($this->searchable, $query, $options, $this->transformer); } } diff --git a/HybridResult.php b/HybridResult.php index e2b8245..81499ba 100644 --- a/HybridResult.php +++ b/HybridResult.php @@ -2,14 +2,14 @@ namespace FOS\ElasticaBundle; -use Elastica_Result; +use Elastica\Result; class HybridResult { protected $result; protected $transformed; - public function __construct(Elastica_Result $result, $transformed = null) + public function __construct(Result $result, $transformed = null) { $this->result = $result; $this->transformed = $transformed; @@ -24,4 +24,4 @@ class HybridResult { return $this->result; } -} \ No newline at end of file +} diff --git a/Index/AliasProcessor.php b/Index/AliasProcessor.php new file mode 100644 index 0000000..d8f608d --- /dev/null +++ b/Index/AliasProcessor.php @@ -0,0 +1,197 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Index; + +use Elastica\Client; +use Elastica\Exception\ExceptionInterface; +use Elastica\Request; +use FOS\ElasticaBundle\Configuration\IndexConfig; +use FOS\ElasticaBundle\Elastica\Index; +use FOS\ElasticaBundle\Exception\AliasIsIndexException; + +class AliasProcessor +{ + /** + * Sets the randomised root name for an index. + * + * @param IndexConfig $indexConfig + * @param Index $index + */ + public function setRootName(IndexConfig $indexConfig, Index $index) + { + $index->overrideName( + sprintf('%s_%s', + $indexConfig->getElasticSearchName(), + date('Y-m-d-His') + ) + ); + } + + /** + * Switches an index to become the new target for an alias. Only applies for + * indexes that are set to use aliases. + * + * $force will delete an index encountered where an alias is expected. + * + * @param IndexConfig $indexConfig + * @param Index $index + * @param bool $force + * + * @throws AliasIsIndexException + * @throws \RuntimeException + */ + public function switchIndexAlias(IndexConfig $indexConfig, Index $index, $force = false) + { + $client = $index->getClient(); + + $aliasName = $indexConfig->getElasticSearchName(); + $oldIndexName = null; + $newIndexName = $index->getName(); + + try { + $oldIndexName = $this->getAliasedIndex($client, $aliasName); + } catch (AliasIsIndexException $e) { + if (!$force) { + throw $e; + } + + $this->deleteIndex($client, $aliasName); + } + + try { + $aliasUpdateRequest = $this->buildAliasUpdateRequest($oldIndexName, $aliasName, $newIndexName); + $client->request('_aliases', 'POST', $aliasUpdateRequest); + } catch (ExceptionInterface $e) { + $this->cleanupRenameFailure($client, $newIndexName, $e); + } + + // Delete the old index after the alias has been switched + if (null !== $oldIndexName) { + $this->deleteIndex($client, $oldIndexName); + } + } + + /** + * Builds an ElasticSearch request to rename or create an alias. + * + * @param string|null $aliasedIndex + * @param string $aliasName + * @param string $newIndexName + * @return array + */ + private function buildAliasUpdateRequest($aliasedIndex, $aliasName, $newIndexName) + { + $aliasUpdateRequest = array('actions' => array()); + if (null !== $aliasedIndex) { + // if the alias is set - add an action to remove it + $aliasUpdateRequest['actions'][] = array( + 'remove' => array('index' => $aliasedIndex, 'alias' => $aliasName), + ); + } + + // add an action to point the alias to the new index + $aliasUpdateRequest['actions'][] = array( + 'add' => array('index' => $newIndexName, 'alias' => $aliasName), + ); + + return $aliasUpdateRequest; + } + + /** + * Cleans up an index when we encounter a failure to rename the alias. + * + * @param Client $client + * @param string $indexName + * @param \Exception $renameAliasException + */ + private function cleanupRenameFailure(Client $client, $indexName, \Exception $renameAliasException) + { + $additionalError = ''; + try { + $this->deleteIndex($client, $indexName); + } catch (ExceptionInterface $deleteNewIndexException) { + $additionalError = sprintf( + 'Tried to delete newly built index %s, but also failed: %s', + $indexName, + $deleteNewIndexException->getMessage() + ); + } + + throw new \RuntimeException(sprintf( + 'Failed to updated index alias: %s. %s', + $renameAliasException->getMessage(), + $additionalError ?: sprintf('Newly built index %s was deleted', $indexName) + ), 0, $renameAliasException); + } + + /** + * Delete an index. + * + * @param Client $client + * @param string $indexName Index name to delete + */ + private function deleteIndex(Client $client, $indexName) + { + try { + $path = sprintf("%s", $indexName); + $client->request($path, Request::DELETE); + } catch (ExceptionInterface $deleteOldIndexException) { + throw new \RuntimeException(sprintf( + 'Failed to delete index %s with message: %s', + $indexName, + $deleteOldIndexException->getMessage() + ), 0, $deleteOldIndexException); + } + } + + /** + * Returns the name of a single index that an alias points to or throws + * an exception if there is more than one. + * + * @param Client $client + * @param string $aliasName Alias name + * + * @return string|null + * + * @throws AliasIsIndexException + */ + private function getAliasedIndex(Client $client, $aliasName) + { + $aliasesInfo = $client->request('_aliases', 'GET')->getData(); + $aliasedIndexes = array(); + + foreach ($aliasesInfo as $indexName => $indexInfo) { + if ($indexName === $aliasName) { + throw new AliasIsIndexException($indexName); + } + if (!isset($indexInfo['aliases'])) { + continue; + } + + $aliases = array_keys($indexInfo['aliases']); + if (in_array($aliasName, $aliases)) { + $aliasedIndexes[] = $indexName; + } + } + + if (count($aliasedIndexes) > 1) { + throw new \RuntimeException(sprintf( + 'Alias %s is used for multiple indexes: [%s]. Make sure it\'s'. + 'either not used or is assigned to one index only', + $aliasName, + implode(', ', $aliasedIndexes) + )); + } + + return array_shift($aliasedIndexes); + } +} diff --git a/Index/IndexManager.php b/Index/IndexManager.php new file mode 100644 index 0000000..98ce870 --- /dev/null +++ b/Index/IndexManager.php @@ -0,0 +1,70 @@ +defaultIndex = $defaultIndex; + $this->indexes = $indexes; + } + + /** + * Gets all registered indexes. + * + * @return array + */ + public function getAllIndexes() + { + return $this->indexes; + } + + /** + * Gets an index by its name. + * + * @param string $name Index to return, or the default index if null + * + * @return Index + * + * @throws \InvalidArgumentException if no index exists for the given name + */ + public function getIndex($name = null) + { + if (null === $name) { + return $this->defaultIndex; + } + + if (!isset($this->indexes[$name])) { + throw new \InvalidArgumentException(sprintf('The index "%s" does not exist', $name)); + } + + return $this->indexes[$name]; + } + + /** + * Gets the default index. + * + * @return Index + */ + public function getDefaultIndex() + { + return $this->defaultIndex; + } +} diff --git a/Index/MappingBuilder.php b/Index/MappingBuilder.php new file mode 100644 index 0000000..e03bf54 --- /dev/null +++ b/Index/MappingBuilder.php @@ -0,0 +1,134 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Index; + +use FOS\ElasticaBundle\Configuration\IndexConfig; +use FOS\ElasticaBundle\Configuration\TypeConfig; + +class MappingBuilder +{ + /** + * Skip adding default information to certain fields. + * + * @var array + */ + private $skipTypes = array('completion'); + + /** + * Builds mappings for an entire index. + * + * @param IndexConfig $indexConfig + * + * @return array + */ + public function buildIndexMapping(IndexConfig $indexConfig) + { + $typeMappings = array(); + foreach ($indexConfig->getTypes() as $typeConfig) { + $typeMappings[$typeConfig->getName()] = $this->buildTypeMapping($typeConfig); + } + + $mapping = array(); + if (!empty($typeMappings)) { + $mapping['mappings'] = $typeMappings; + } + // 'warmers' => $indexConfig->getWarmers(), + + $settings = $indexConfig->getSettings(); + if (!empty($settings)) { + $mapping['settings'] = $settings; + } + + return $mapping; + } + + /** + * Builds mappings for a single type. + * + * @param TypeConfig $typeConfig + * + * @return array + */ + public function buildTypeMapping(TypeConfig $typeConfig) + { + $mapping = $typeConfig->getMapping(); + + if (null !== $typeConfig->getDynamicDateFormats()) { + $mapping['dynamic_date_formats'] = $typeConfig->getDynamicDateFormats(); + } + + if (null !== $typeConfig->getDateDetection()) { + $mapping['date_detection'] = $typeConfig->getDateDetection(); + } + + if (null !== $typeConfig->getNumericDetection()) { + $mapping['numeric_detection'] = $typeConfig->getNumericDetection(); + } + + if ($typeConfig->getIndexAnalyzer()) { + $mapping['index_analyzer'] = $typeConfig->getIndexAnalyzer(); + } + + if ($typeConfig->getSearchAnalyzer()) { + $mapping['search_analyzer'] = $typeConfig->getSearchAnalyzer(); + } + + if (isset($mapping['dynamic_templates']) and empty($mapping['dynamic_templates'])) { + unset($mapping['dynamic_templates']); + } + + $this->fixProperties($mapping['properties']); + if (!$mapping['properties']) { + unset($mapping['properties']); + } + + if ($typeConfig->getModel()) { + $mapping['_meta']['model'] = $typeConfig->getModel(); + } + + if (empty($mapping)) { + // Empty mapping, we want it encoded as a {} instead of a [] + $mapping = new \stdClass(); + } + + return $mapping; + } + + /** + * Fixes any properties and applies basic defaults for any field that does not have + * required options. + * + * @param $properties + */ + private function fixProperties(&$properties) + { + foreach ($properties as $name => &$property) { + unset($property['property_path']); + + if (!isset($property['type'])) { + $property['type'] = 'string'; + } + if ($property['type'] == 'multi_field' && isset($property['fields'])) { + $this->fixProperties($property['fields']); + } + if (isset($property['properties'])) { + $this->fixProperties($property['properties']); + } + if (in_array($property['type'], $this->skipTypes)) { + continue; + } + if (!isset($property['store'])) { + $property['store'] = true; + } + } + } +} diff --git a/Index/Resetter.php b/Index/Resetter.php new file mode 100644 index 0000000..2b39157 --- /dev/null +++ b/Index/Resetter.php @@ -0,0 +1,158 @@ +aliasProcessor = $aliasProcessor; + $this->configManager = $configManager; + $this->dispatcher = $eventDispatcher; + $this->indexManager = $indexManager; + $this->mappingBuilder = $mappingBuilder; + } + + /** + * Deletes and recreates all indexes. + * + * @param bool $populating + * @param bool $force + */ + public function resetAllIndexes($populating = false, $force = false) + { + foreach ($this->configManager->getIndexNames() as $name) { + $this->resetIndex($name, $populating, $force); + } + } + + /** + * Deletes and recreates the named index. If populating, creates a new index + * with a randomised name for an alias to be set after population. + * + * @param string $indexName + * @param bool $populating + * @param bool $force If index exists with same name as alias, remove it + * + * @throws \InvalidArgumentException if no index exists for the given name + */ + public function resetIndex($indexName, $populating = false, $force = false) + { + $indexConfig = $this->configManager->getIndexConfiguration($indexName); + $index = $this->indexManager->getIndex($indexName); + + $event = new IndexResetEvent($indexName, $populating, $force); + $this->dispatcher->dispatch(IndexResetEvent::PRE_INDEX_RESET, $event); + + if ($indexConfig->isUseAlias()) { + $this->aliasProcessor->setRootName($indexConfig, $index); + } + + $mapping = $this->mappingBuilder->buildIndexMapping($indexConfig); + $index->create($mapping, true); + + if (!$populating and $indexConfig->isUseAlias()) { + $this->aliasProcessor->switchIndexAlias($indexConfig, $index, $force); + } + + $this->dispatcher->dispatch(IndexResetEvent::POST_INDEX_RESET, $event); + } + + /** + * Deletes and recreates a mapping type for the named index. + * + * @param string $indexName + * @param string $typeName + * + * @throws \InvalidArgumentException if no index or type mapping exists for the given names + * @throws ResponseException + */ + public function resetIndexType($indexName, $typeName) + { + $typeConfig = $this->configManager->getTypeConfiguration($indexName, $typeName); + $type = $this->indexManager->getIndex($indexName)->getType($typeName); + + $event = new TypeResetEvent($indexName, $typeName); + $this->dispatcher->dispatch(TypeResetEvent::PRE_TYPE_RESET, $event); + + try { + $type->delete(); + } catch (ResponseException $e) { + if (strpos($e->getMessage(), 'TypeMissingException') === false) { + throw $e; + } + } + + $mapping = new Mapping(); + foreach ($this->mappingBuilder->buildTypeMapping($typeConfig) as $name => $field) { + $mapping->setParam($name, $field); + } + + $type->setMapping($mapping); + + $this->dispatcher->dispatch(TypeResetEvent::POST_TYPE_RESET, $event); + } + + /** + * A command run when a population has finished. + * + * @param string $indexName + */ + public function postPopulate($indexName) + { + $indexConfig = $this->configManager->getIndexConfiguration($indexName); + + if ($indexConfig->isUseAlias()) { + $index = $this->indexManager->getIndex($indexName); + $this->aliasProcessor->switchIndexAlias($indexConfig, $index); + } + } +} diff --git a/IndexManager.php b/IndexManager.php index ae844d0..e7c74c8 100644 --- a/IndexManager.php +++ b/IndexManager.php @@ -2,60 +2,11 @@ namespace FOS\ElasticaBundle; -class IndexManager +use FOS\ElasticaBundle\Index\IndexManager as BaseIndexManager; + +/** + * @deprecated Use \FOS\ElasticaBundle\Index\IndexManager + */ +class IndexManager extends BaseIndexManager { - protected $indexesByName; - protected $defaultIndexName; - - /** - * Constructor. - * - * @param array $indexesByName - * @param \Elastica_Index $defaultIndex - */ - public function __construct(array $indexesByName, \Elastica_Index $defaultIndex) - { - $this->indexesByName = $indexesByName; - $this->defaultIndexName = $defaultIndex->getName(); - } - - /** - * Gets all registered indexes - * - * @return array - */ - public function getAllIndexes() - { - return $this->indexesByName; - } - - /** - * Gets an index by its name - * - * @param string $name Index to return, or the default index if null - * @return \Elastica_Index - * @throws \InvalidArgumentException if no index exists for the given name - */ - public function getIndex($name = null) - { - if (null === $name) { - $name = $this->defaultIndexName; - } - - if (!isset($this->indexesByName[$name])) { - throw new \InvalidArgumentException(sprintf('The index "%s" does not exist', $name)); - } - - return $this->indexesByName[$name]; - } - - /** - * Gets the default index - * - * @return \Elastica_Index - */ - public function getDefaultIndex() - { - return $this->getIndex($this->defaultIndexName); - } } diff --git a/LICENSE b/LICENSE deleted file mode 100644 index d0ba561..0000000 --- a/LICENSE +++ /dev/null @@ -1,18 +0,0 @@ -Copyright (c) 2012 Exercise.com - -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Logger/ElasticaLogger.php b/Logger/ElasticaLogger.php index e8840da..8cb5cbc 100644 --- a/Logger/ElasticaLogger.php +++ b/Logger/ElasticaLogger.php @@ -2,7 +2,7 @@ namespace FOS\ElasticaBundle\Logger; -use Symfony\Component\HttpKernel\Log\LoggerInterface; +use Psr\Log\LoggerInterface; /** * Logger for the Elastica. @@ -12,41 +12,55 @@ use Symfony\Component\HttpKernel\Log\LoggerInterface; * * @author Gordon Franke */ -class ElasticaLogger +class ElasticaLogger implements LoggerInterface { + /** + * @var LoggerInterface + */ protected $logger; - protected $queries; + + /** + * @var array + */ + protected $queries = array(); + + /** + * @var boolean + */ protected $debug; /** * Constructor. * * @param LoggerInterface|null $logger The Symfony logger - * @param bool $debug + * @param boolean $debug */ public function __construct(LoggerInterface $logger = null, $debug = false) { $this->logger = $logger; - $this->queries = array(); $this->debug = $debug; } /** * Logs a query. * - * @param string $path Path to call - * @param string $method Rest method to use (GET, POST, DELETE, PUT) - * @param array $data arguments - * @param float $time execution time + * @param string $path Path to call + * @param string $method Rest method to use (GET, POST, DELETE, PUT) + * @param array $data Arguments + * @param float $time Execution time + * @param array $connection Host, port, transport, and headers of the query + * @param array $query Arguments */ - public function logQuery($path, $method, $data, $time) + public function logQuery($path, $method, $data, $time, $connection = array(), $query = array()) { if ($this->debug) { $this->queries[] = array( 'path' => $path, 'method' => $method, 'data' => $data, - 'executionMS' => $time + 'executionMS' => $time, + 'connection' => $connection, + 'queryString' => $query, ); } @@ -75,4 +89,76 @@ class ElasticaLogger { return $this->queries; } + + /** + * {@inheritdoc} + */ + public function emergency($message, array $context = array()) + { + return $this->logger->emergency($message, $context); + } + + /** + * {@inheritdoc} + */ + public function alert($message, array $context = array()) + { + return $this->logger->alert($message, $context); + } + + /** + * {@inheritdoc} + */ + public function critical($message, array $context = array()) + { + return $this->logger->critical($message, $context); + } + + /** + * {@inheritdoc} + */ + public function error($message, array $context = array()) + { + return $this->logger->error($message, $context); + } + + /** + * {@inheritdoc} + */ + public function warning($message, array $context = array()) + { + return $this->logger->warning($message, $context); + } + + /** + * {@inheritdoc} + */ + public function notice($message, array $context = array()) + { + return $this->logger->notice($message, $context); + } + + /** + * {@inheritdoc} + */ + public function info($message, array $context = array()) + { + return $this->logger->info($message, $context); + } + + /** + * {@inheritdoc} + */ + public function debug($message, array $context = array()) + { + return $this->logger->debug($message, $context); + } + + /** + * {@inheritdoc} + */ + public function log($level, $message, array $context = array()) + { + return $this->logger->log($level, $message, $context); + } } diff --git a/Manager/RepositoryManager.php b/Manager/RepositoryManager.php index 7701ec9..1a0601c 100644 --- a/Manager/RepositoryManager.php +++ b/Manager/RepositoryManager.php @@ -5,6 +5,7 @@ namespace FOS\ElasticaBundle\Manager; use Doctrine\Common\Annotations\Reader; use FOS\ElasticaBundle\Finder\FinderInterface; use RuntimeException; + /** * @author Richard Miller * @@ -24,16 +25,16 @@ class RepositoryManager implements RepositoryManagerInterface public function addEntity($entityName, FinderInterface $finder, $repositoryName = null) { - $this->entities[$entityName]= array(); + $this->entities[$entityName] = array(); $this->entities[$entityName]['finder'] = $finder; $this->entities[$entityName]['repositoryName'] = $repositoryName; } /** - * Return repository for entity + * Return repository for entity. * * Returns custom repository if one specified otherwise - * returns a basic respository. + * returns a basic repository. */ public function getRepository($entityName) { @@ -58,23 +59,26 @@ class RepositoryManager implements RepositoryManagerInterface } $refClass = new \ReflectionClass($entityName); - $annotation = $this->reader->getClassAnnotation($refClass, 'FOS\\ElasticaBundle\\Configuration\\Search'); + $annotation = $this->reader->getClassAnnotation($refClass, 'FOS\\ElasticaBundle\\Annotation\\Search'); if ($annotation) { $this->entities[$entityName]['repositoryName'] = $annotation->repositoryClass; + return $annotation->repositoryClass; } return 'FOS\ElasticaBundle\Repository'; } + /** + * @param string $entityName + */ private function createRepository($entityName) { - $repositoryName = $this->getRepositoryName($entityName); - if (!class_exists($repositoryName)) { + if (!class_exists($repositoryName = $this->getRepositoryName($entityName))) { throw new RuntimeException(sprintf('%s repository for %s does not exist', $repositoryName, $entityName)); } + return new $repositoryName($this->entities[$entityName]['finder']); } - } diff --git a/Manager/RepositoryManagerInterface.php b/Manager/RepositoryManagerInterface.php index c831d35..0a38e0e 100644 --- a/Manager/RepositoryManagerInterface.php +++ b/Manager/RepositoryManagerInterface.php @@ -12,7 +12,6 @@ use FOS\ElasticaBundle\Finder\FinderInterface; */ interface RepositoryManagerInterface { - /** * Adds entity name and its finder. * Custom repository class name can also be added. @@ -24,7 +23,7 @@ interface RepositoryManagerInterface public function addEntity($entityName, FinderInterface $finder, $repositoryName = null); /** - * Return repository for entity + * Return repository for entity. * * Returns custom repository if one specified otherwise * returns a basic repository. @@ -32,5 +31,4 @@ interface RepositoryManagerInterface * @param string $entityName */ public function getRepository($entityName); - } diff --git a/Paginator/FantaPaginatorAdapter.php b/Paginator/FantaPaginatorAdapter.php index 4307abc..8f9a60a 100644 --- a/Paginator/FantaPaginatorAdapter.php +++ b/Paginator/FantaPaginatorAdapter.php @@ -3,14 +3,13 @@ namespace FOS\ElasticaBundle\Paginator; use Pagerfanta\Adapter\AdapterInterface; -use FOS\ElasticaBundle\Paginator\PaginatorAdapterInterface; class FantaPaginatorAdapter implements AdapterInterface { private $adapter; /** - * @param PaginatorAdapterInterface $adapter + * @param \FOS\ElasticaBundle\Paginator\PaginatorAdapterInterface $adapter */ public function __construct(PaginatorAdapterInterface $adapter) { @@ -21,8 +20,6 @@ class FantaPaginatorAdapter implements AdapterInterface * Returns the number of results. * * @return integer The number of results. - * - * @api */ public function getNbResults() { @@ -30,14 +27,34 @@ class FantaPaginatorAdapter implements AdapterInterface } /** - * Returns an slice of the results. + * Returns Facets. + * + * @return mixed + */ + public function getFacets() + { + return $this->adapter->getFacets(); + } + + /** + * Returns Aggregations. + * + * @return mixed + * + * @api + */ + public function getAggregations() + { + return $this->adapter->getAggregations(); + } + + /** + * Returns a slice of the results. * * @param integer $offset The offset. * @param integer $length The length. * * @return array|\Traversable The slice. - * - * @api */ public function getSlice($offset, $length) { diff --git a/Paginator/PaginatorAdapterInterface.php b/Paginator/PaginatorAdapterInterface.php index 29eb66c..adf7df2 100644 --- a/Paginator/PaginatorAdapterInterface.php +++ b/Paginator/PaginatorAdapterInterface.php @@ -2,18 +2,14 @@ namespace FOS\ElasticaBundle\Paginator; -use FOS\ElasticaBundle\Paginator\PartialResultsInterface; - interface PaginatorAdapterInterface { /** * Returns the number of results. * * @return integer The number of results. - * - * @api */ - function getTotalHits(); + public function getTotalHits(); /** * Returns an slice of the results. @@ -22,8 +18,20 @@ interface PaginatorAdapterInterface * @param integer $length The length. * * @return PartialResultsInterface - * - * @api */ - function getResults($offset, $length); + public function getResults($offset, $length); + + /** + * Returns Facets. + * + * @return mixed + */ + public function getFacets(); + + /** + * Returns Aggregations. + * + * @return mixed + */ + public function getAggregations(); } diff --git a/Paginator/PartialResultsInterface.php b/Paginator/PartialResultsInterface.php index 9efe7f3..156d27f 100644 --- a/Paginator/PartialResultsInterface.php +++ b/Paginator/PartialResultsInterface.php @@ -8,24 +8,27 @@ interface PartialResultsInterface * Returns the paginated results. * * @return array - * - * @api */ - function toArray(); + public function toArray(); /** * Returns the number of results. * * @return integer The number of results. - * - * @api */ - function getTotalHits(); + public function getTotalHits(); /** - * Returns the facets + * Returns the facets. * * @return array */ - function getFacets(); -} \ No newline at end of file + public function getFacets(); + + /** + * Returns the aggregations. + * + * @return array + */ + public function getAggregations(); +} diff --git a/Paginator/RawPaginatorAdapter.php b/Paginator/RawPaginatorAdapter.php index 9a80d39..2eebde0 100644 --- a/Paginator/RawPaginatorAdapter.php +++ b/Paginator/RawPaginatorAdapter.php @@ -2,54 +2,96 @@ namespace FOS\ElasticaBundle\Paginator; -use Elastica_Searchable; -use Elastica_Query; -use Elastica_ResultSet; -use FOS\ElasticaBundle\Paginator\PaginatorAdapterInterface; -use FOS\ElasticaBundle\Paginator\RawPartialResults; -use FOS\ElasticaBundle\Paginator\PartialResultsInterface; +use Elastica\SearchableInterface; +use Elastica\Query; +use Elastica\ResultSet; +use InvalidArgumentException; /** - * Allows pagination of Elastica_Query. Does not map results + * Allows pagination of Elastica\Query. Does not map results. */ class RawPaginatorAdapter implements PaginatorAdapterInterface { /** - * @var Elastica_Searchable the object to search in + * @var SearchableInterface the object to search in */ - private $searchable = null; + private $searchable; /** - * @var Elastica_Query the query to search + * @var Query the query to search */ - private $query = null; + private $query; + + /** + * @var array search options + */ + private $options; + + /** + * @var integer the number of hits + */ + private $totalHits; + + /** + * @var array for the facets + */ + private $facets; + + /** + * @var array for the aggregations + */ + private $aggregations; /** * @see PaginatorAdapterInterface::__construct * - * @param Elastica_Searchable $searchable the object to search in - * @param Elastica_Query $query the query to search + * @param SearchableInterface $searchable the object to search in + * @param Query $query the query to search + * @param array $options */ - public function __construct(Elastica_Searchable $searchable, Elastica_Query $query) + public function __construct(SearchableInterface $searchable, Query $query, array $options = array()) { $this->searchable = $searchable; $this->query = $query; + $this->options = $options; } /** * Returns the paginated results. * - * @param $offset - * @param $itemCountPerPage - * @return Elastica_ResultSet + * @param integer $offset + * @param integer $itemCountPerPage + * + * @throws \InvalidArgumentException + * + * @return ResultSet */ protected function getElasticaResults($offset, $itemCountPerPage) { + $offset = (integer) $offset; + $itemCountPerPage = (integer) $itemCountPerPage; + $size = $this->query->hasParam('size') + ? (integer) $this->query->getParam('size') + : null; + + if (null !== $size && $size < $offset + $itemCountPerPage) { + $itemCountPerPage = $size - $offset; + } + + if ($itemCountPerPage < 1) { + throw new InvalidArgumentException('$itemCountPerPage must be greater than zero'); + } + $query = clone $this->query; $query->setFrom($offset); - $query->setLimit($itemCountPerPage); + $query->setSize($itemCountPerPage); - return $this->searchable->search($query); + $resultSet = $this->searchable->search($query, $this->options); + $this->totalHits = $resultSet->getTotalHits(); + $this->facets = $resultSet->getFacets(); + $this->aggregations = $resultSet->getAggregations(); + + return $resultSet; } /** @@ -57,6 +99,7 @@ class RawPaginatorAdapter implements PaginatorAdapterInterface * * @param int $offset * @param int $itemCountPerPage + * * @return PartialResultsInterface */ public function getResults($offset, $itemCountPerPage) @@ -67,10 +110,60 @@ class RawPaginatorAdapter implements PaginatorAdapterInterface /** * Returns the number of results. * + * If genuineTotal is provided as true, total hits is returned from the + * hits.total value from the search results instead of just returning + * the requested size. + * + * @param boolean $genuineTotal + * * @return integer The number of results. */ - public function getTotalHits() + public function getTotalHits($genuineTotal = false) { - return $this->searchable->search($this->query)->getTotalHits(); + if (! isset($this->totalHits)) { + $this->totalHits = $this->searchable->count($this->query); + } + + return $this->query->hasParam('size') && !$genuineTotal + ? min($this->totalHits, (integer) $this->query->getParam('size')) + : $this->totalHits; + } + + /** + * Returns Facets. + * + * @return mixed + */ + public function getFacets() + { + if (! isset($this->facets)) { + $this->facets = $this->searchable->search($this->query)->getFacets(); + } + + return $this->facets; + } + + /** + * Returns Aggregations. + * + * @return mixed + */ + public function getAggregations() + { + if (!isset($this->aggregations)) { + $this->aggregations = $this->searchable->search($this->query)->getAggregations(); + } + + return $this->aggregations; + } + + /** + * Returns the Query. + * + * @return Query the search query + */ + public function getQuery() + { + return $this->query; } } diff --git a/Paginator/RawPartialResults.php b/Paginator/RawPartialResults.php index ef1ed6b..e45c6dd 100644 --- a/Paginator/RawPartialResults.php +++ b/Paginator/RawPartialResults.php @@ -2,21 +2,20 @@ namespace FOS\ElasticaBundle\Paginator; -use FOS\ElasticaBundle\Paginator\PartialResultsInterface; -use Elastica_ResultSet; -use Elastica_Result; +use Elastica\ResultSet; +use Elastica\Result; /** - * Raw partial results transforms to a simple array + * Raw partial results transforms to a simple array. */ class RawPartialResults implements PartialResultsInterface { protected $resultSet; /** - * @param \Elastica_ResultSet $resultSet + * @param ResultSet $resultSet */ - public function __construct(Elastica_ResultSet $resultSet) + public function __construct(ResultSet $resultSet) { $this->resultSet = $resultSet; } @@ -26,7 +25,7 @@ class RawPartialResults implements PartialResultsInterface */ public function toArray() { - return array_map(function(Elastica_Result $result) { + return array_map(function (Result $result) { return $result->getSource(); }, $this->resultSet->getResults()); } @@ -48,6 +47,18 @@ class RawPartialResults implements PartialResultsInterface return $this->resultSet->getFacets(); } - return null; + return; } -} \ No newline at end of file + + /** + * {@inheritDoc} + */ + public function getAggregations() + { + if ($this->resultSet->hasAggregations()) { + return $this->resultSet->getAggregations(); + } + + return; + } +} diff --git a/Paginator/TransformedPaginatorAdapter.php b/Paginator/TransformedPaginatorAdapter.php index 59a94d0..bf152fb 100644 --- a/Paginator/TransformedPaginatorAdapter.php +++ b/Paginator/TransformedPaginatorAdapter.php @@ -3,25 +3,25 @@ namespace FOS\ElasticaBundle\Paginator; use FOS\ElasticaBundle\Transformer\ElasticaToModelTransformerInterface; -use FOS\ElasticaBundle\Paginator\TransformedPartialResults; -use Elastica_Searchable; -use Elastica_Query; +use Elastica\SearchableInterface; +use Elastica\Query; /** - * Allows pagination of Elastica_Query + * Allows pagination of \Elastica\Query. */ class TransformedPaginatorAdapter extends RawPaginatorAdapter { private $transformer; /** - * @param Elastica_Searchable $searchable the object to search in - * @param Elastica_Query $query the query to search + * @param SearchableInterface $searchable the object to search in + * @param Query $query the query to search + * @param array $options * @param ElasticaToModelTransformerInterface $transformer the transformer for fetching the results */ - public function __construct(Elastica_Searchable $searchable, Elastica_Query $query, ElasticaToModelTransformerInterface $transformer) + public function __construct(SearchableInterface $searchable, Query $query, array $options = array(), ElasticaToModelTransformerInterface $transformer) { - parent::__construct($searchable, $query); + parent::__construct($searchable, $query, $options); $this->transformer = $transformer; } diff --git a/Paginator/TransformedPartialResults.php b/Paginator/TransformedPartialResults.php index 7afd6d4..c9470c3 100644 --- a/Paginator/TransformedPartialResults.php +++ b/Paginator/TransformedPartialResults.php @@ -3,21 +3,20 @@ namespace FOS\ElasticaBundle\Paginator; use FOS\ElasticaBundle\Transformer\ElasticaToModelTransformerInterface; -use FOS\ElasticaBundle\Paginator\RawPartialResults; -use Elastica_ResultSet; +use Elastica\ResultSet; /** - * Partial transformed result set + * Partial transformed result set. */ class TransformedPartialResults extends RawPartialResults { protected $transformer; /** - * @param \Elastica_ResultSet $resultSet + * @param ResultSet $resultSet * @param \FOS\ElasticaBundle\Transformer\ElasticaToModelTransformerInterface $transformer */ - public function __construct(Elastica_ResultSet $resultSet, ElasticaToModelTransformerInterface $transformer) + public function __construct(ResultSet $resultSet, ElasticaToModelTransformerInterface $transformer) { parent::__construct($resultSet); @@ -31,4 +30,4 @@ class TransformedPartialResults extends RawPartialResults { return $this->transformer->transform($this->resultSet->getResults()); } -} \ No newline at end of file +} diff --git a/Persister/ObjectPersister.php b/Persister/ObjectPersister.php index db94880..92dc005 100644 --- a/Persister/ObjectPersister.php +++ b/Persister/ObjectPersister.php @@ -2,13 +2,15 @@ namespace FOS\ElasticaBundle\Persister; +use Psr\Log\LoggerInterface; +use Elastica\Exception\BulkException; use FOS\ElasticaBundle\Transformer\ModelToElasticaTransformerInterface; -use Elastica_Type; -use Elastica_Document; +use Elastica\Type; +use Elastica\Document; /** * Inserts, replaces and deletes single documents in an elastica type - * Accepts domain model objects and converts them to elastica documents + * Accepts domain model objects and converts them to elastica documents. * * @author Thibault Duplessis */ @@ -18,8 +20,9 @@ class ObjectPersister implements ObjectPersisterInterface protected $transformer; protected $objectClass; protected $fields; + protected $logger; - public function __construct(Elastica_Type $type, ModelToElasticaTransformerInterface $transformer, $objectClass, array $fields) + public function __construct(Type $type, ModelToElasticaTransformerInterface $transformer, $objectClass, array $fields) { $this->type = $type; $this->transformer = $transformer; @@ -27,75 +30,161 @@ class ObjectPersister implements ObjectPersisterInterface $this->fields = $fields; } + /** + * If the ObjectPersister handles a given object. + * + * @param object $object + * + * @return bool + */ + public function handlesObject($object) + { + return $object instanceof $this->objectClass; + } + + /** + * @param LoggerInterface $logger + */ + public function setLogger(LoggerInterface $logger) + { + $this->logger = $logger; + } + + /** + * Log exception if logger defined for persister belonging to the current listener, otherwise re-throw. + * + * @param BulkException $e + * + * @throws BulkException + */ + private function log(BulkException $e) + { + if (! $this->logger) { + throw $e; + } + + $this->logger->error($e); + } + /** * Insert one object into the type - * The object will be transformed to an elastica document + * The object will be transformed to an elastica document. * * @param object $object */ public function insertOne($object) { - $document = $this->transformToElasticaDocument($object); - $this->type->addDocument($document); + $this->insertMany(array($object)); } /** - * Replaces one object in the type + * Replaces one object in the type. * * @param object $object - * @return null **/ public function replaceOne($object) { - $document = $this->transformToElasticaDocument($object); - $this->type->deleteById($document->getId()); - $this->type->addDocument($document); + $this->replaceMany(array($object)); } /** - * Deletes one object in the type + * Deletes one object in the type. * * @param object $object - * @return null **/ public function deleteOne($object) { - $document = $this->transformToElasticaDocument($object); - $this->type->deleteById($document->getId()); + $this->deleteMany(array($object)); } /** - * Deletes one object in the type by id + * Deletes one object in the type by id. * * @param mixed $id - * - * @return null **/ public function deleteById($id) { - $this->type->deleteById($id); + $this->deleteManyByIdentifiers(array($id)); } - /** - * Inserts an array of objects in the type + * Bulk insert an array of objects in the type for the given method. * * @param array $objects array of domain model objects - **/ + * @param string Method to call + */ public function insertMany(array $objects) { $documents = array(); foreach ($objects as $object) { $documents[] = $this->transformToElasticaDocument($object); } - $this->type->addDocuments($documents); + try { + $this->type->addDocuments($documents); + } catch (BulkException $e) { + $this->log($e); + } } /** - * Transforms an object to an elastica document + * Bulk update an array of objects in the type. Create document if it does not already exist. + * + * @param array $objects array of domain model objects + */ + public function replaceMany(array $objects) + { + $documents = array(); + foreach ($objects as $object) { + $document = $this->transformToElasticaDocument($object); + $document->setDocAsUpsert(true); + $documents[] = $document; + } + + try { + $this->type->updateDocuments($documents); + } catch (BulkException $e) { + $this->log($e); + } + } + + /** + * Bulk deletes an array of objects in the type. + * + * @param array $objects array of domain model objects + */ + public function deleteMany(array $objects) + { + $documents = array(); + foreach ($objects as $object) { + $documents[] = $this->transformToElasticaDocument($object); + } + try { + $this->type->deleteDocuments($documents); + } catch (BulkException $e) { + $this->log($e); + } + } + + /** + * Bulk deletes records from an array of identifiers. + * + * @param array $identifiers array of domain model object identifiers + */ + public function deleteManyByIdentifiers(array $identifiers) + { + try { + $this->type->getIndex()->getClient()->deleteIds($identifiers, $this->type->getIndex(), $this->type); + } catch (BulkException $e) { + $this->log($e); + } + } + + /** + * Transforms an object to an elastica document. * * @param object $object - * @return Elastica_Document the elastica document + * + * @return Document the elastica document */ public function transformToElasticaDocument($object) { diff --git a/Persister/ObjectPersisterInterface.php b/Persister/ObjectPersisterInterface.php index a50bcc8..f624971 100644 --- a/Persister/ObjectPersisterInterface.php +++ b/Persister/ObjectPersisterInterface.php @@ -4,47 +4,75 @@ namespace FOS\ElasticaBundle\Persister; /** * Inserts, replaces and deletes single documents in an elastica type - * Accepts domain model objects and converts them to elastica documents + * Accepts domain model objects and converts them to elastica documents. * * @author Thibault Duplessis */ interface ObjectPersisterInterface { + /** + * Checks if this persister can handle the given object or not. + * + * @param mixed $object + * + * @return boolean + */ + public function handlesObject($object); + /** * Insert one object into the type - * The object will be transformed to an elastica document + * The object will be transformed to an elastica document. * * @param object $object */ - function insertOne($object); + public function insertOne($object); /** - * Replaces one object in the type + * Replaces one object in the type. * * @param object $object **/ - function replaceOne($object); + public function replaceOne($object); /** - * Deletes one object in the type + * Deletes one object in the type. * * @param object $object **/ - function deleteOne($object); + public function deleteOne($object); /** - * Deletes one object in the type by id + * Deletes one object in the type by id. * * @param mixed $id - * - * @return null - **/ - function deleteById($id); + */ + public function deleteById($id); /** - * Inserts an array of objects in the type + * Bulk inserts an array of objects in the type. * * @param array $objects array of domain model objects - **/ - function insertMany(array $objects); + */ + public function insertMany(array $objects); + + /** + * Bulk updates an array of objects in the type. + * + * @param array $objects array of domain model objects + */ + public function replaceMany(array $objects); + + /** + * Bulk deletes an array of objects in the type. + * + * @param array $objects array of domain model objects + */ + public function deleteMany(array $objects); + + /** + * Bulk deletes records from an array of identifiers. + * + * @param array $identifiers array of domain model object identifiers + */ + public function deleteManyByIdentifiers(array $identifiers); } diff --git a/Persister/ObjectSerializerPersister.php b/Persister/ObjectSerializerPersister.php new file mode 100644 index 0000000..792aa9a --- /dev/null +++ b/Persister/ObjectSerializerPersister.php @@ -0,0 +1,50 @@ + + */ +class ObjectSerializerPersister extends ObjectPersister +{ + protected $serializer; + + /** + * @param Type $type + * @param ModelToElasticaTransformerInterface $transformer + * @param string $objectClass + * @param callable $serializer + */ + public function __construct(Type $type, ModelToElasticaTransformerInterface $transformer, $objectClass, $serializer) + { + parent::__construct($type, $transformer, $objectClass, array()); + + $this->serializer = $serializer; + } + + /** + * Transforms an object to an elastica document + * with just the identifier set. + * + * @param object $object + * + * @return Document the elastica document + */ + public function transformToElasticaDocument($object) + { + $document = $this->transformer->transform($object, array()); + + $data = call_user_func($this->serializer, $object); + $document->setData($data); + + return $document; + } +} diff --git a/Propel/ElasticaToModelTransformer.php b/Propel/ElasticaToModelTransformer.php index 76e4233..d143478 100644 --- a/Propel/ElasticaToModelTransformer.php +++ b/Propel/ElasticaToModelTransformer.php @@ -3,74 +3,75 @@ namespace FOS\ElasticaBundle\Propel; use FOS\ElasticaBundle\HybridResult; +use FOS\ElasticaBundle\Transformer\AbstractElasticaToModelTransformer; use FOS\ElasticaBundle\Transformer\ElasticaToModelTransformerInterface; -use Symfony\Component\Form\Util\PropertyPath; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; /** - * Maps Elastica documents with Propel objects - * This mapper assumes an exact match between - * elastica documents ids and propel object ids + * Maps Elastica documents with Propel objects. + * + * This mapper assumes an exact match between Elastica document IDs and Propel + * entity IDs. * * @author William Durand */ -class ElasticaToModelTransformer implements ElasticaToModelTransformerInterface +class ElasticaToModelTransformer extends AbstractElasticaToModelTransformer { /** - * Class of the model to map to the elastica documents + * Propel model class to map to Elastica documents. * * @var string */ protected $objectClass = null; /** - * Optional parameters + * Transformer options. * * @var array */ protected $options = array( 'hydrate' => true, - 'identifier' => 'id' + 'identifier' => 'id', ); /** - * Instantiates a new Mapper + * Constructor. * * @param string $objectClass - * @param array $options + * @param array $options */ public function __construct($objectClass, array $options = array()) { $this->objectClass = $objectClass; - $this->options = array_merge($this->options, $options); + $this->options = array_merge($this->options, $options); } /** - * Transforms an array of elastica objects into an array of - * model objects fetched from the propel repository + * Transforms an array of Elastica document into an array of Propel entities + * fetched from the database. * - * @param \Elastica_Document[] $elasticaObjects array of elastica objects - * @return array + * @param array $elasticaObjects + * + * @return array|\ArrayObject */ public function transform(array $elasticaObjects) { - $ids = array_map(function(\Elastica_Document $elasticaObject) { - return $elasticaObject->getId(); - }, $elasticaObjects); + $ids = array(); + foreach ($elasticaObjects as $elasticaObject) { + $ids[] = $elasticaObject->getId(); + } $objects = $this->findByIdentifiers($ids, $this->options['hydrate']); - $identifierProperty = new PropertyPath($this->options['identifier']); - - // sort objects in the order of ids + // Sort objects in the order of their IDs $idPos = array_flip($ids); + $identifier = $this->options['identifier']; + $sortCallback = $this->getSortingClosure($idPos, $identifier); + if (is_object($objects)) { - $objects->uasort(function($a, $b) use ($idPos, $identifierProperty) { - return $idPos[$identifierProperty->getValue($a)] > $idPos[$identifierProperty->getValue($b)]; - }); + $objects->uasort($sortCallback); } else { - usort($objects, function($a, $b) use ($idPos, $identifierProperty) { - return $idPos[$identifierProperty->getValue($a)] > $idPos[$identifierProperty->getValue($b)]; - }); + usort($objects, $sortCallback); } return $objects; @@ -84,7 +85,7 @@ class ElasticaToModelTransformer implements ElasticaToModelTransformerInterface $objects = $this->transform($elasticaObjects); $result = array(); - for ($i = 0; $i < count($elasticaObjects); $i++) { + for ($i = 0, $j = count($elasticaObjects); $i < $j; $i++) { $result[] = new HybridResult($elasticaObjects[$i], $objects[$i]); } @@ -108,11 +109,15 @@ class ElasticaToModelTransformer implements ElasticaToModelTransformerInterface } /** - * Fetch objects for theses identifier values + * Fetch Propel entities for the given identifier values. * - * @param array $identifierValues ids values - * @param boolean $hydrate whether or not to hydrate the objects, false returns arrays - * @return array of objects or arrays + * If $hydrate is false, the returned array elements will be arrays. + * Otherwise, the results will be hydrated to instances of the model class. + * + * @param array $identifierValues Identifier values + * @param boolean $hydrate Whether or not to hydrate the results + * + * @return array */ protected function findByIdentifiers(array $identifierValues, $hydrate) { @@ -122,7 +127,7 @@ class ElasticaToModelTransformer implements ElasticaToModelTransformerInterface $query = $this->createQuery($this->objectClass, $this->options['identifier'], $identifierValues); - if (!$hydrate) { + if (! $hydrate) { return $query->toArray(); } @@ -132,9 +137,10 @@ class ElasticaToModelTransformer implements ElasticaToModelTransformerInterface /** * Create a query to use in the findByIdentifiers() method. * - * @param string $class the model class - * @param string $identifierField like 'id' - * @param array $identifierValues ids values + * @param string $class Propel model class + * @param string $identifierField Identifier field name (e.g. "id") + * @param array $identifierValues Identifier values + * * @return \ModelCriteria */ protected function createQuery($class, $identifierField, array $identifierValues) @@ -142,13 +148,13 @@ class ElasticaToModelTransformer implements ElasticaToModelTransformerInterface $queryClass = $class.'Query'; $filterMethod = 'filterBy'.$this->camelize($identifierField); - return $queryClass::create() - ->$filterMethod($identifierValues) - ; + return $queryClass::create()->$filterMethod($identifierValues); } /** * @see https://github.com/doctrine/common/blob/master/lib/Doctrine/Common/Util/Inflector.php + * + * @param string $str */ private function camelize($str) { diff --git a/Propel/Provider.php b/Propel/Provider.php index f173e72..9864d53 100644 --- a/Propel/Provider.php +++ b/Propel/Provider.php @@ -5,39 +5,69 @@ namespace FOS\ElasticaBundle\Propel; use FOS\ElasticaBundle\Provider\AbstractProvider; /** - * Propel provider + * Propel provider. * * @author William Durand */ class Provider extends AbstractProvider { /** - * @see FOS\ElasticaBundle\Provider\ProviderInterface::populate() + * {@inheritDoc} */ - public function populate(\Closure $loggerClosure = null) + public function doPopulate($options, \Closure $loggerClosure = null) { - $queryClass = $this->objectClass . 'Query'; + $queryClass = $this->objectClass.'Query'; $nbObjects = $queryClass::create()->count(); - for ($offset = 0; $offset < $nbObjects; $offset += $this->options['batch_size']) { - if ($loggerClosure) { - $stepStartTime = microtime(true); + $offset = $options['offset']; + + for (; $offset < $nbObjects; $offset += $options['batch_size']) { + $objects = $queryClass::create() + ->limit($options['batch_size']) + ->offset($offset) + ->find() + ->getArrayCopy(); + $objects = $this->filterObjects($options, $objects); + if (!empty($objects)) { + $this->objectPersister->insertMany($objects); } - $objects = $queryClass::create() - ->limit($this->options['batch_size']) - ->offset($offset) - ->find(); - - $this->objectPersister->insertMany($objects->getArrayCopy()); + usleep($options['sleep']); if ($loggerClosure) { - $stepNbObjects = count($objects); - $stepCount = $stepNbObjects + $offset; - $percentComplete = 100 * $stepCount / $nbObjects; - $objectsPerSecond = $stepNbObjects / (microtime(true) - $stepStartTime); - $loggerClosure(sprintf('%0.1f%% (%d/%d), %d objects/s', $percentComplete, $stepCount, $nbObjects, $objectsPerSecond)); + $loggerClosure($options['batch_size'], $nbObjects); } } } + + /** + * {@inheritDoc} + */ + protected function configureOptions() + { + parent::configureOptions(); + + $this->resolver->setDefaults(array( + 'clear_object_manager' => true, + 'debug_logging' => false, + 'ignore_errors' => false, + 'offset' => 0, + 'query_builder_method' => null, + 'sleep' => 0 + )); + } + + /** + * {@inheritDoc} + */ + protected function disableLogging() + { + } + + /** + * {@inheritDoc} + */ + protected function enableLogging($logger) + { + } } diff --git a/Provider/AbstractProvider.php b/Provider/AbstractProvider.php index daa57ae..f05ab98 100644 --- a/Provider/AbstractProvider.php +++ b/Provider/AbstractProvider.php @@ -3,28 +3,193 @@ namespace FOS\ElasticaBundle\Provider; use FOS\ElasticaBundle\Persister\ObjectPersisterInterface; -use FOS\ElasticaBundle\Provider\ProviderInterface; +use Symfony\Component\OptionsResolver\OptionsResolver; +/** + * AbstractProvider. + */ abstract class AbstractProvider implements ProviderInterface { + /** + * @var array + */ + protected $baseOptions; + + /** + * @var string + */ protected $objectClass; + + /** + * @var ObjectPersisterInterface + */ protected $objectPersister; - protected $options; + + /** + * @var OptionsResolver + */ + protected $resolver; + + /** + * @var IndexableInterface + */ + private $indexable; /** * Constructor. * * @param ObjectPersisterInterface $objectPersister + * @param IndexableInterface $indexable * @param string $objectClass - * @param array $options + * @param array $baseOptions */ - public function __construct(ObjectPersisterInterface $objectPersister, $objectClass, array $options = array()) - { - $this->objectPersister = $objectPersister; + public function __construct( + ObjectPersisterInterface $objectPersister, + IndexableInterface $indexable, + $objectClass, + array $baseOptions = array() + ) { + $this->baseOptions = $baseOptions; + $this->indexable = $indexable; $this->objectClass = $objectClass; + $this->objectPersister = $objectPersister; + $this->resolver = new OptionsResolver(); + $this->configureOptions(); + } - $this->options = array_merge(array( + /** + * {@inheritDoc} + */ + public function populate(\Closure $loggerClosure = null, array $options = array()) + { + $options = $this->resolveOptions($options); + + $logger = !$options['debug_logging'] ? + $this->disableLogging() : + null; + + $this->doPopulate($options, $loggerClosure); + + if (null !== $logger) { + $this->enableLogging($logger); + } + } + + /** + * Disables logging and returns the logger that was previously set. + * + * @return mixed + */ + abstract protected function disableLogging(); + + /** + * Perform actual population. + * + * @param array $options + * @param \Closure $loggerClosure + */ + abstract protected function doPopulate($options, \Closure $loggerClosure = null); + + /** + * Reenables the logger with the previously returned logger from disableLogging();. + * + * @param mixed $logger + * + * @return mixed + */ + abstract protected function enableLogging($logger); + + /** + * Configures the option resolver. + */ + protected function configureOptions() + { + $this->resolver->setDefaults(array( 'batch_size' => 100, - ), $options); + 'skip_indexable_check' => false, + )); + $this->resolver->setAllowedTypes(array( + 'batch_size' => 'int' + )); + + $this->resolver->setRequired(array( + 'indexName', + 'typeName', + )); + } + + + /** + * Filters objects away if they are not indexable. + * + * @param array $options + * @param array $objects + * @return array + */ + protected function filterObjects(array $options, array $objects) + { + if ($options['skip_indexable_check']) { + return $objects; + } + + $index = $options['indexName']; + $type = $options['typeName']; + + $return = array(); + foreach ($objects as $object) { + if (!$this->indexable->isObjectIndexable($index, $type, $object)) { + continue; + } + + $return[] = $object; + } + + return $return; + } + + /** + * Checks if a given object should be indexed or not. + * + * @deprecated To be removed in 4.0 + * + * @param object $object + * + * @return bool + */ + protected function isObjectIndexable($object) + { + return $this->indexable->isObjectIndexable( + $this->baseOptions['indexName'], + $this->baseOptions['typeName'], + $object + ); + } + + /** + * Get string with RAM usage information (current and peak). + * + * @deprecated To be removed in 4.0 + * + * @return string + */ + protected function getMemoryUsage() + { + $memory = round(memory_get_usage() / (1024 * 1024)); // to get usage in Mo + $memoryMax = round(memory_get_peak_usage() / (1024 * 1024)); // to get max usage in Mo + + return sprintf('(RAM : current=%uMo peak=%uMo)', $memory, $memoryMax); + } + + /** + * Merges the base options provided by the class with options passed to the populate + * method and runs them through the resolver. + * + * @param array $options + * + * @return array + */ + protected function resolveOptions(array $options) + { + return $this->resolver->resolve(array_merge($this->baseOptions, $options)); } } diff --git a/Provider/Indexable.php b/Provider/Indexable.php new file mode 100644 index 0000000..c26da5a --- /dev/null +++ b/Provider/Indexable.php @@ -0,0 +1,240 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Provider; + +use Symfony\Component\DependencyInjection\ContainerInterface; +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\ExpressionLanguage\SyntaxError; +use Symfony\Component\PropertyAccess\PropertyAccess; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; + +class Indexable implements IndexableInterface +{ + /** + * An array of raw configured callbacks for all types. + * + * @var array + */ + private $callbacks = array(); + + /** + * @var \Symfony\Component\DependencyInjection\ContainerInterface + */ + private $container; + + /** + * An instance of ExpressionLanguage. + * + * @var ExpressionLanguage + */ + private $expressionLanguage; + + /** + * An array of initialised callbacks. + * + * @var array + */ + private $initialisedCallbacks = array(); + + /** + * PropertyAccessor instance. + * + * @var PropertyAccessorInterface + */ + private $propertyAccessor; + + /** + * @param array $callbacks + * @param ContainerInterface $container + */ + public function __construct(array $callbacks, ContainerInterface $container) + { + $this->callbacks = $callbacks; + $this->container = $container; + $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); + } + + /** + * Return whether the object is indexable with respect to the callback. + * + * @param string $indexName + * @param string $typeName + * @param mixed $object + * + * @return bool + */ + public function isObjectIndexable($indexName, $typeName, $object) + { + $type = sprintf('%s/%s', $indexName, $typeName); + $callback = $this->getCallback($type, $object); + if (!$callback) { + return true; + } + + if ($callback instanceof Expression) { + return (bool) $this->getExpressionLanguage()->evaluate($callback, array( + 'object' => $object, + $this->getExpressionVar($object) => $object, + )); + } + + return is_string($callback) + ? call_user_func(array($object, $callback)) + : call_user_func($callback, $object); + } + + /** + * Builds and initialises a callback. + * + * @param string $type + * @param object $object + * + * @return mixed + */ + private function buildCallback($type, $object) + { + if (!array_key_exists($type, $this->callbacks)) { + return; + } + + $callback = $this->callbacks[$type]; + + if (is_callable($callback) or is_callable(array($object, $callback))) { + return $callback; + } + + if (is_array($callback) && !is_object($callback[0])) { + return $this->processArrayToCallback($type, $callback); + } + + if (is_string($callback)) { + return $this->buildExpressionCallback($type, $object, $callback); + } + + throw new \InvalidArgumentException(sprintf('Callback for type "%s" is not a valid callback.', $type)); + } + + /** + * Processes a string expression into an Expression. + * + * @param string $type + * @param mixed $object + * @param string $callback + * + * @return Expression + */ + private function buildExpressionCallback($type, $object, $callback) + { + $expression = $this->getExpressionLanguage(); + if (!$expression) { + throw new \RuntimeException('Unable to process an expression without the ExpressionLanguage component.'); + } + + try { + $callback = new Expression($callback); + $expression->compile($callback, array( + 'object', $this->getExpressionVar($object) + )); + + return $callback; + } catch (SyntaxError $e) { + throw new \InvalidArgumentException(sprintf( + 'Callback for type "%s" is an invalid expression', + $type + ), $e->getCode(), $e); + } + } + + /** + * Retreives a cached callback, or creates a new callback if one is not found. + * + * @param string $type + * @param object $object + * + * @return mixed + */ + private function getCallback($type, $object) + { + if (!array_key_exists($type, $this->initialisedCallbacks)) { + $this->initialisedCallbacks[$type] = $this->buildCallback($type, $object); + } + + return $this->initialisedCallbacks[$type]; + } + + /** + * Returns the ExpressionLanguage class if it is available. + * + * @return ExpressionLanguage|null + */ + private function getExpressionLanguage() + { + if (null === $this->expressionLanguage && class_exists('Symfony\Component\ExpressionLanguage\ExpressionLanguage')) { + $this->expressionLanguage = new ExpressionLanguage(); + } + + return $this->expressionLanguage; + } + + /** + * Returns the variable name to be used to access the object when using the ExpressionLanguage + * component to parse and evaluate an expression. + * + * @param mixed $object + * + * @return string + */ + private function getExpressionVar($object = null) + { + if (!is_object($object)) { + return 'object'; + } + + $ref = new \ReflectionClass($object); + + return strtolower($ref->getShortName()); + } + + /** + * Processes an array into a callback. Replaces the first element with a service if + * it begins with an @. + * + * @param string $type + * @param array $callback + * @return array + */ + private function processArrayToCallback($type, array $callback) + { + list($class, $method) = $callback + array(null, '__invoke'); + + if (strpos($class, '@') === 0) { + $service = $this->container->get(substr($class, 1)); + $callback = array($service, $method); + + if (!is_callable($callback)) { + throw new \InvalidArgumentException(sprintf( + 'Method "%s" on service "%s" is not callable.', + $method, + substr($class, 1) + )); + } + + return $callback; + } + + throw new \InvalidArgumentException(sprintf( + 'Unable to parse callback array for type "%s"', + $type + )); + } +} diff --git a/Provider/IndexableInterface.php b/Provider/IndexableInterface.php new file mode 100644 index 0000000..0d9f047 --- /dev/null +++ b/Provider/IndexableInterface.php @@ -0,0 +1,26 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Provider; + +interface IndexableInterface +{ + /** + * Checks if an object passed should be indexable or not. + * + * @param string $indexName + * @param string $typeName + * @param mixed $object + * + * @return bool + */ + public function isObjectIndexable($indexName, $typeName, $object); +} diff --git a/Provider/ProviderInterface.php b/Provider/ProviderInterface.php index 710fd4b..1ba977b 100644 --- a/Provider/ProviderInterface.php +++ b/Provider/ProviderInterface.php @@ -3,7 +3,7 @@ namespace FOS\ElasticaBundle\Provider; /** - * Insert application domain objects into elastica types + * Insert application domain objects into elastica types. * * @author Thibault Duplessis */ @@ -12,8 +12,15 @@ interface ProviderInterface /** * Persists all domain objects to ElasticSearch for this provider. * + * The closure can expect 2 or 3 arguments: + * * The step size + * * The total number of objects + * * A message to output in error conditions (not normally provided) + * * @param \Closure $loggerClosure + * @param array $options + * * @return */ - function populate(\Closure $loggerClosure = null); + public function populate(\Closure $loggerClosure = null, array $options = array()); } diff --git a/Provider/ProviderRegistry.php b/Provider/ProviderRegistry.php index ed5b499..9fc9e3c 100644 --- a/Provider/ProviderRegistry.php +++ b/Provider/ProviderRegistry.php @@ -2,8 +2,6 @@ namespace FOS\ElasticaBundle\Provider; -use Symfony\Component\DependencyInjection\ContainerBuilder; - use Symfony\Component\DependencyInjection\ContainerAwareInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -59,8 +57,10 @@ class ProviderRegistry implements ContainerAwareInterface * * Providers will be indexed by "type" strings in the returned array. * - * @param string $index - * @return array of ProviderInterface instances + * @param string $index + * + * @return ProviderInterface[] + * * @throws \InvalidArgumentException if no providers were registered for the index */ public function getIndexProviders($index) @@ -83,7 +83,9 @@ class ProviderRegistry implements ContainerAwareInterface * * @param string $index * @param string $type + * * @return ProviderInterface + * * @throws \InvalidArgumentException if no provider was registered for the index and type */ public function getProvider($index, $type) diff --git a/README.md b/README.md index dc9df26..797d629 100644 --- a/README.md +++ b/README.md @@ -1,696 +1,35 @@ -[Elastica](https://github.com/ruflin/Elastica) integration in Symfony2 +FOSElasticaBundle +================= -## Installation +This bundle provides integration with [ElasticSearch](http://www.elasticsearch.org) and [Elastica](https://github.com/ruflin/Elastica) with +Symfony2. Features include: -### Install elasticsearch +- Integrates the Elastica library into a Symfony2 environment +- Automatically generate mappings using a serializer +- Listeners for Doctrine events for automatic indexing -http://www.elasticsearch.org/guide/reference/setup/installation.html +> **Note** Propel support is limited and contributions fixing issues are welcome! -### Install Elastica +[![Build Status](https://secure.travis-ci.org/FriendsOfSymfony/FOSElasticaBundle.png?branch=master)](http://travis-ci.org/FriendsOfSymfony/FOSElasticaBundle) [![Total Downloads](https://poser.pugx.org/FriendsOfSymfony/elastica-bundle/downloads.png)](https://packagist.org/packages/FriendsOfSymfony/elastica-bundle) [![Latest Stable Version](https://poser.pugx.org/FriendsOfSymfony/elastica-bundle/v/stable.png)](https://packagist.org/packages/FriendsOfSymfony/elastica-bundle) [![Latest Unstable Version](https://poser.pugx.org/friendsofsymfony/elastica-bundle/v/unstable.svg)](https://packagist.org/packages/friendsofsymfony/elastica-bundle) +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/FriendsOfSymfony/FOSElasticaBundle/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/FriendsOfSymfony/FOSElasticaBundle/?branch=master) -#### Download +Documentation +------------- -**With submodule** +Documentation for FOSElasticaBundle is in `Resources/doc/index.md` - `git submodule add git://github.com/ruflin/Elastica vendor/elastica` +[Read the documentation for 3.1.x](https://github.com/FriendsOfSymfony/FOSElasticaBundle/blob/master/Resources/doc/index.md) -**With clone** +[Read the documentation for 3.0.x](https://github.com/FriendsOfSymfony/FOSElasticaBundle/blob/3.0.x/Resources/doc/index.md) - `git clone git://github.com/ruflin/Elastica vendor/elastica` +Installation +------------ -**Using the vendors script** +Installation instructions can be found in the [documentation](https://github.com/FriendsOfSymfony/FOSElasticaBundle/blob/master/Resources/doc/setup.md) -Add the following lines to your deps file: +License +------- - [Elastica] - git=git://github.com/ruflin/Elastica.git - target=elastica +This bundle is under the MIT license. See the complete license in the bundle: -#### Register autoloading - - // app/autoload.php - - $loader->registerPrefixes(array( - ... - 'Elastica' => __DIR__.'/../vendor/elastica/lib', - )); - -### Install ElasticaBundle - -Use the master branch with Symfony2 master only, use the 2.0 branch with Symfony2.0.x releases. - -#### Download - -**With submodule** - - `git submodule add git://github.com/Exercise/FOSElasticaBundle vendor/bundles/FOS/ElasticaBundle` - -**With clone** - - `git clone git://github.com/Exercise/FOSElasticaBundle vendor/bundles/FOS/ElasticaBundle` - -**With the vendors script** - -Add the following lines to your deps file: - - [FOSElasticaBundle] - git=git://github.com/Exercise/FOSElasticaBundle.git - target=bundles/FOS/ElasticaBundle - -For the 2.0 branch for use with Symfony2.0.x releases add the following: - - [FOSElasticaBundle] - git=git://github.com/Exercise/FOSElasticaBundle.git - target=bundles/FOS/ElasticaBundle - version=origin/2.0 - -Run the vendors script: - -```bash -$ php bin/vendors install -``` -#### Register autoloading - - // app/autoload.php - - $loader->registerNamespaces(array( - ... - 'FOS' => __DIR__.'/../vendor/bundles', - )); - -#### Register the bundle - - // app/AppKernel.php - - public function registerBundles() - { - return array( - // ... - new FOS\ElasticaBundle\FOSElasticaBundle(), - // ... - ); - } - -### Basic configuration - -#### Declare a client - -Elasticsearch client is comparable to a database connection. -Most of the time, you will need only one. - - #app/config/config.yml - fos_elastica: - clients: - default: { host: localhost, port: 9200 } - -#### Declare an index - -Elasticsearch index is comparable to Doctrine entity manager. -Most of the time, you will need only one. - - fos_elastica: - clients: - default: { host: localhost, port: 9200 } - indexes: - website: - client: default - -Here we created a "website" index, that uses our "default" client. - -Our index is now available as a service: `fos_elastica.index.website`. It is an instance of `Elastica_Index`. - -If you need to have different index name from the service name, for example, -in order to have different indexes for different environments then you can -use the ```index_name``` key to change the index name. The service name will -remain the same across the environments: - - fos_elastica: - clients: - default: { host: localhost, port: 9200 } - indexes: - website: - client: default - index_name: website_qa - -The service id will be `fos_elastica.index.website` but the underlying index name is website_qa. - -#### Declare a type - -Elasticsearch type is comparable to Doctrine entity repository. - - fos_elastica: - clients: - default: { host: localhost, port: 9200 } - indexes: - website: - client: default - types: - user: - mappings: - username: { boost: 5 } - firstName: { boost: 3 } - lastName: { boost: 3 } - aboutMe: ~ - -Our type is now available as a service: `fos_elastica.index.website.user`. It is an instance of `Elastica_Type`. - -### Declaring parent field - - fos_elastica: - clients: - default: { host: localhost, port: 9200 } - indexes: - website: - client: default - types: - comment: - mappings: - post: {_parent: { type: "post", identifier: "id" } } - date: { boost: 5 } - content: ~ - -### Declaring `nested` or `object` - - fos_elastica: - clients: - default: { host: localhost, port: 9200 } - indexes: - website: - client: default - types: - post: - mappings: - date: { boost: 5 } - title: { boost: 3 } - content: ~ - comments: - type: "nested" - properties: - date: { boost: 5 } - content: ~ - -### Populate the types - - php app/console fos:elastica:populate - -This command deletes and creates the declared indexes and types. -It applies the configured mappings to the types. - -This command needs providers to insert new documents in the elasticsearch types. -There are 2 ways to create providers. -If your elasticsearch type matches a Doctrine repository or a Propel query, go for the persistence automatic provider. -Or, for complete flexibility, go for manual provider. - -#### Persistence automatic provider - -If we want to index the entities from a Doctrine repository or a Propel query, -some configuration will let ElasticaBundle do it for us. - - fos_elastica: - clients: - default: { host: localhost, port: 9200 } - indexes: - website: - client: default - types: - user: - mappings: - username: { boost: 5 } - firstName: { boost: 3 } - # more mappings... - persistence: - driver: orm # orm, mongodb, propel are available - model: Application\UserBundle\Entity\User - provider: ~ - -Three drivers are actually supported: orm, mongodb, and propel. - -##### Use a custom Doctrine query builder - -You can control which entities will be indexed by specifying a custom query builder method. - - persistence: - driver: orm - model: Application\UserBundle\Entity\User - provider: - query_builder_method: createIsActiveQueryBuilder - -Your repository must implement this method and return a Doctrine query builder. - -> **Propel** doesn't support this feature yet. - -##### Change the batch size - -By default, ElasticaBundle will index documents by packets of 100. -You can change this value in the provider configuration. - - persistence: - driver: orm - model: Application\UserBundle\Entity\User - provider: - batch_size: 100 - -##### Change the document identifier field - -By default, ElasticaBundle will use the `id` field of your entities as the elasticsearch document identifier. -You can change this value in the persistence configuration. - - persistence: - driver: orm - model: Application\UserBundle\Entity\User - identifier: id - -#### Manual provider - -Create a service with the tag "fos_elastica.provider" and attributes for the -index and type for which the service will provide. - - - - - - -Its class must implement `FOS\ElasticaBundle\Provider\ProviderInterface`. - - userType = $userType; - } - - /** - * Insert the repository objects in the type index - * - * @param Closure $loggerClosure - */ - public function populate(Closure $loggerClosure = null) - { - if ($loggerClosure) { - $loggerClosure('Indexing users'); - } - - $document = new \Elastica_Document(); - $document->setData(array('username' => 'Bob')); - $this->userType->addDocuments(array($document)); - } - } - -You will find a more complete implementation example in `src/FOS/ElasticaBundle/Doctrine/AbstractProvider.php`. - -### Search - -You can just use the index and type Elastica objects, provided as services, to perform searches. - - /** var Elastica_Type */ - $userType = $this->container->get('fos_elastica.index.website.user'); - - /** var Elastica_ResultSet */ - $resultSet = $userType->search('bob'); - -#### Doctrine/Propel finder - -If your elasticsearch type is bound to a Doctrine entity repository or a Propel query, -you can get your entities instead of Elastica results when you perform a search. -Declare that you want a Doctrine/Propel finder in your configuration: - - fos_elastica: - clients: - default: { host: localhost, port: 9200 } - indexes: - website: - client: default - types: - user: - mappings: - # your mappings - persistence: - driver: orm - model: Application\UserBundle\Entity\User - provider: ~ - finder: ~ - -You can now use the `fos_elastica.finder.website.user` service: - - /** var FOS\ElasticaBundle\Finder\TransformedFinder */ - $finder = $container->get('fos_elastica.finder.website.user'); - - /** var array of Acme\UserBundle\Entity\User */ - $users = $finder->find('bob'); - - /** var array of Acme\UserBundle\Entity\User limited to 10 results */ - $users = $finder->find('bob', 10); - -You can even get paginated results! - -Pagerfanta: - - /** var Pagerfanta\Pagerfanta */ - $userPaginator = $finder->findPaginated('bob'); - -Knp paginator: - - $paginator = $this->get('knp_paginator'); - $userPaginator = $paginator->paginate($finder->createPaginatorAdapter('bob')); - -You can also get both the Elastica results and the entities together from the finder. -You can then access the score, highlights etc. from the Elastica_Result whilst -still also getting the entity. - - /** var array of FOS\ElasticaBundle\HybridResult */ - $hybridResults = $finder->findHybrid('bob'); - foreach ($hybridResults as $hybridResult) { - - /** var Acme\UserBundle\Entity\User */ - $user = $hybridResult->getTransformed(); - - /** var Elastica_Result */ - $result = $hybridResult->getResult(); - } - -##### Index wide finder - -You can also define a finder that will work on the entire index. Adjust your index -configuration as per below: - - fos_elastica: - indexes: - website: - client: default - finder: ~ - -You can now use the index wide finder service `fos_elastica.finder.website`: - - /** var FOS\ElasticaBundle\Finder\MappedFinder */ - $finder = $container->get('fos_elastica.finder.website'); - - // Returns a mixed array of any objects mapped - $results = $finder->find('bob'); - -#### Repositories - -As well as using the finder service for a particular Doctrine/Propel entity you -can use a manager service for each driver and get a repository for an entity to search -against. This allows you to use the same service rather than the particular finder. For -example: - - /** var FOS\ElasticaBundle\Manager\RepositoryManager */ - $repositoryManager = $container->get('fos_elastica.manager.orm'); - - /** var FOS\ElasticaBundle\Repository */ - $repository = $repositoryManager->getRepository('UserBundle:User'); - - /** var array of Acme\UserBundle\Entity\User */ - $users = $repository->find('bob'); - -You can also specify the full name of the entity instead of the shortcut syntax: - - /** var FOS\ElasticaBundle\Repository */ - $repository = $repositoryManager->getRepository('Application\UserBundle\Entity\User'); - -> The **2.0** branch doesn't support using `UserBundle:User` style syntax and you must use the full name of the entity. . - -##### Default Manager - -If you are only using one driver then its manager service is automatically aliased -to `fos_elastica.manager`. So the above example could be simplified to: - - /** var FOS\ElasticaBundle\Manager\RepositoryManager */ - $repositoryManager = $container->get('fos_elastica.manager'); - - /** var FOS\ElasticaBundle\Repository */ - $repository = $repositoryManager->getRepository('UserBundle:User'); - - /** var array of Acme\UserBundle\Entity\User */ - $users = $repository->find('bob'); - -If you use multiple drivers then you can choose which one is aliased to `fos_elastica.manager` -using the `default_manager` parameter: - - fos_elastica: - default_manager: mongodb #defauults to orm - clients: - default: { host: localhost, port: 9200 } - #-- - -##### Custom Repositories - -As well as the default repository you can create a custom repository for an entity and add -methods for particular searches. These need to extend `FOS\ElasticaBundle\Repository` to have -access to the finder: - -``` -find($query); - } -} -``` - -To use the custom repository specify it in the mapping for the entity: - - fos_elastica: - clients: - default: { host: localhost, port: 9200 } - indexes: - website: - client: default - types: - user: - mappings: - # your mappings - persistence: - driver: orm - model: Application\UserBundle\Entity\User - provider: ~ - finder: ~ - repository: Acme\ElasticaBundle\SearchRepository\UserRepository - -Then the custom queries will be available when using the repository returned from the manager: - - /** var FOS\ElasticaBundle\Manager\RepositoryManager */ - $repositoryManager = $container->get('fos_elastica.manager'); - - /** var FOS\ElasticaBundle\Repository */ - $repository = $repositoryManager->getRepository('UserBundle:User'); - - /** var array of Acme\UserBundle\Entity\User */ - $users = $repository->findWithCustomQuery('bob'); - -Alternatively you can specify the custom repository using an annotation in the entity: - -``` - **Propel** doesn't support this feature yet. - -### Checking an entity method for listener - -If you use listeners to update your index, you may need to validate your -entities before you index them (e.g. only index "public" entities). Typically, -you'll want the listener to be consistent with the provider's query criteria. -This may be achieved by using the `is_indexable_callback` config parameter: - - persistence: - listener: - is_indexable_callback: "isPublic" - -If `is_indexable_callback` is a string and the entity has a method with the -specified name, the listener will only index entities for which the method -returns `true`. Additionally, you may provide a service and method name pair: - - persistence: - listener: - is_indexable_callback: [ "%custom_service_id%", "isIndexable" ] - -In this case, the callback will be the `isIndexable()` method on the specified -service and the object being considered for indexing will be passed as the only -argument. This allows you to do more complex validation (e.g. ACL checks). - -As you might expect, new entities will only be indexed if the callback returns -`true`. Additionally, modified entities will be updated or removed from the -index depending on whether the callback returns `true` or `false`, respectively. -The delete listener disregards the callback. - -> **Propel** doesn't support this feature yet. - -### Advanced elasticsearch configuration - -Any setting can be specified when declaring a type. For example, to enable a custom analyzer, you could write: - - fos_elastica: - indexes: - doc: - settings: - index: - analysis: - analyzer: - my_analyzer: - type: custom - tokenizer: lowercase - filter : [my_ngram] - filter: - my_ngram: - type: "nGram" - min_gram: 3 - max_gram: 5 - types: - blog: - mappings: - title: { boost: 8, analyzer: my_analyzer } - -### Overriding the Client class to suppress exceptions - -By default, exceptions from the Elastica client library will propagate through -the bundle's Client class. For instance, if the elasticsearch server is offline, -issuing a request will result in an `Elastica_Exception_Client` being thrown. -Depending on your needs, it may be desirable to suppress these exceptions and -allow searches to fail silently. - -One way to achieve this is to override the `fos_elastica.client.class` service -container parameter with a custom class. In the following example, we override -the `Client::request()` method and return the equivalent of an empty search -response if an exception occurred. - -``` -container->get('fos_elastica.finder.website.article'); -$boolQuery = new \Elastica_Query_Bool(); - -$fieldQuery = new \Elastica_Query_Text(); -$fieldQuery->setFieldQuery('title', 'I am a title string'); -$fieldQuery->setFieldParam('title', 'analyzer', 'my_analyzer'); -$boolQuery->addShould($fieldQuery); - -$tagsQuery = new \Elastica_Query_Terms(); -$tagsQuery->setTerms('tags', array('tag1', 'tag2')); -$boolQuery->addShould($tagsQuery); - -$categoryQuery = new \Elastica_Query_Terms(); -$categoryQuery->setTerms('categoryIds', array('1', '2', '3')); -$boolQuery->addMust($categoryQuery); - -$data = $finder->find($boolQuery); -``` - -Configuration: - -```yaml -fos_elastica: - clients: - default: { host: localhost, port: 9200 } - indexes: - site: - settings: - index: - analysis: - analyzer: - my_analyzer: - type: snowball - language: English - types: - article: - mappings: - title: { boost: 10, analyzer: my_analyzer } - tags: - categoryIds: - persistence: - driver: orm - model: Acme\DemoBundle\Entity\Article - provider: - finder: -``` + Resources/meta/LICENSE diff --git a/Repository.php b/Repository.php index dde07c6..04a51c5 100644 --- a/Repository.php +++ b/Repository.php @@ -7,7 +7,7 @@ use FOS\ElasticaBundle\Finder\PaginatedFinderInterface; /** * @author Richard Miller * - * Basic respoitory to be extended to hold custom queries to be run + * Basic repository to be extended to hold custom queries to be run * in the finder. */ class Repository @@ -19,23 +19,49 @@ class Repository $this->finder = $finder; } - public function find($query, $limit=null) + /** + * @param mixed $query + * @param integer $limit + * @param array $options + * + * @return array + */ + public function find($query, $limit = null, $options = array()) { - return $this->finder->find($query, $limit); + return $this->finder->find($query, $limit, $options); } - public function findHybrid($query, $limit=null) + /** + * @param mixed $query + * @param integer $limit + * @param array $options + * + * @return mixed + */ + public function findHybrid($query, $limit = null, $options = array()) { - return $this->finder->findHybrid($query, $limit); + return $this->finder->findHybrid($query, $limit, $options); } - public function findPaginated($query) + /** + * @param mixed $query + * @param array $options + * + * @return \Pagerfanta\Pagerfanta + */ + public function findPaginated($query, $options = array()) { - return $this->finder->findPaginated($query); + return $this->finder->findPaginated($query, $options); } - public function createPaginatorAdapter($query) + /** + * @param string $query + * @param array $options + * + * @return Paginator\PaginatorAdapterInterface + */ + public function createPaginatorAdapter($query, $options = array()) { - return $this->finder->createPaginatorAdapter($query); + return $this->finder->createPaginatorAdapter($query, $options); } } diff --git a/Resetter.php b/Resetter.php index 7614675..2067579 100644 --- a/Resetter.php +++ b/Resetter.php @@ -2,100 +2,11 @@ namespace FOS\ElasticaBundle; +use FOS\ElasticaBundle\Index\Resetter as BaseResetter; + /** - * Deletes and recreates indexes + * @deprecated Use \FOS\ElasticaBundle\Index\Resetter */ -class Resetter +class Resetter extends BaseResetter { - protected $indexConfigsByName; - - /** - * Constructor. - * - * @param array $indexConfigsByName - */ - public function __construct(array $indexConfigsByName) - { - $this->indexConfigsByName = $indexConfigsByName; - } - - /** - * Deletes and recreates all indexes - */ - public function resetAllIndexes() - { - foreach ($this->indexConfigsByName as $indexConfig) { - $indexConfig['index']->create($indexConfig['config'], true); - } - } - - /** - * Deletes and recreates the named index - * - * @param string $indexName - * @throws \InvalidArgumentException if no index exists for the given name - */ - public function resetIndex($indexName) - { - $indexConfig = $this->getIndexConfig($indexName); - $indexConfig['index']->create($indexConfig['config'], true); - } - - /** - * Deletes and recreates a mapping type for the named index - * - * @param string $indexName - * @param string $typeName - * @throws \InvalidArgumentException if no index or type mapping exists for the given names - */ - public function resetIndexType($indexName, $typeName) - { - $indexConfig = $this->getIndexConfig($indexName); - - if (!isset($indexConfig['config']['mappings'][$typeName]['properties'])) { - throw new \InvalidArgumentException(sprintf('The mapping for index "%s" and type "%s" does not exist.', $indexName, $typeName)); - } - - $type = $indexConfig['index']->getType($typeName); - $type->delete(); - $mapping = $this->createMapping($indexConfig['config']['mappings'][$typeName]); - $type->setMapping($mapping); - } - - /** - * create type mapping object - * - * @param array $indexConfig - * @return \Elastica_Type_Mapping - */ - protected function createMapping($indexConfig) - { - $mapping = \Elastica_Type_Mapping::create($indexConfig['properties']); - - foreach($indexConfig['properties'] as $type) { - if (!empty($type['_parent']) && $type['_parent'] !== '~') { - $mapping->setParam('_parent', array('type' => $type['_parent']['type'])); - } - } - - return $mapping; - } - - /** - * Gets an index config by its name - * - * @param string $indexName Index name - * - * @param $indexName - * @return array - * @throws \InvalidArgumentException if no index config exists for the given name - */ - protected function getIndexConfig($indexName) - { - if (!isset($this->indexConfigsByName[$indexName])) { - throw new \InvalidArgumentException(sprintf('The configuration for index "%s" does not exist.', $indexName)); - } - - return $this->indexConfigsByName[$indexName]; - } } diff --git a/Resources/config/config.xml b/Resources/config/config.xml index a0819fb..06f0cda 100644 --- a/Resources/config/config.xml +++ b/Resources/config/config.xml @@ -1,80 +1,51 @@ + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> - FOS\ElasticaBundle\Client - Elastica_Index - Elastica_Type + FOS\ElasticaBundle\Elastica\Client FOS\ElasticaBundle\Logger\ElasticaLogger FOS\ElasticaBundle\DataCollector\ElasticaDataCollector - FOS\ElasticaBundle\Manager\RepositoryManager - FOS\ElasticaBundle\Transformer\ElasticaToModelTransformerCollection - FOS\ElasticaBundle\Provider\ProviderRegistry + FOS\ElasticaBundle\Index\MappingBuilder + Symfony\Component\PropertyAccess\PropertyAccessor + + + + + + + + + + + + + + + + + + + + + + + + %kernel.debug% - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - diff --git a/Resources/config/index.xml b/Resources/config/index.xml new file mode 100644 index 0000000..3ae2e50 --- /dev/null +++ b/Resources/config/index.xml @@ -0,0 +1,53 @@ + + + + + + FOS\ElasticaBundle\Index\AliasProcessor + FOS\ElasticaBundle\Finder\TransformedFinder + FOS\ElasticaBundle\Elastica\Index + FOS\ElasticaBundle\Provider\Indexable + FOS\ElasticaBundle\Index\IndexManager + FOS\ElasticaBundle\Index\Resetter + Elastica\Type + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/config/mongodb.xml b/Resources/config/mongodb.xml index 53c82f9..97ed16e 100644 --- a/Resources/config/mongodb.xml +++ b/Resources/config/mongodb.xml @@ -4,33 +4,46 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> + + FOS\ElasticaBundle\Doctrine\MongoDB\SliceFetcher + FOS\ElasticaBundle\Doctrine\MongoDB\Provider + FOS\ElasticaBundle\Doctrine\Listener + FOS\ElasticaBundle\Doctrine\MongoDB\ElasticaToModelTransformer + FOS\ElasticaBundle\Doctrine\RepositoryManager + + + + - + + - + + - + - - - + + + null - + + + + - + - - diff --git a/Resources/config/orm.xml b/Resources/config/orm.xml index b47cfc1..8147d51 100644 --- a/Resources/config/orm.xml +++ b/Resources/config/orm.xml @@ -1,37 +1,49 @@ + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" + xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> + + + FOS\ElasticaBundle\Doctrine\ORM\SliceFetcher + FOS\ElasticaBundle\Doctrine\ORM\Provider + FOS\ElasticaBundle\Doctrine\Listener + FOS\ElasticaBundle\Doctrine\ORM\ElasticaToModelTransformer + FOS\ElasticaBundle\Doctrine\RepositoryManager + + + - + + - + + - + - - - - + + + null - + + + + - - - + + + - - diff --git a/Resources/config/persister.xml b/Resources/config/persister.xml new file mode 100644 index 0000000..8bd4dca --- /dev/null +++ b/Resources/config/persister.xml @@ -0,0 +1,27 @@ + + + + + + FOS\ElasticaBundle\Persister\ObjectPersister + FOS\ElasticaBundle\Persister\ObjectSerializerPersister + + + + + + + + + + + + + + + + + + diff --git a/Resources/config/propel.xml b/Resources/config/propel.xml index a6fc32f..297e735 100644 --- a/Resources/config/propel.xml +++ b/Resources/config/propel.xml @@ -4,9 +4,9 @@ xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd"> - + @@ -14,12 +14,13 @@ + + + - + - - diff --git a/Resources/config/provider.xml b/Resources/config/provider.xml new file mode 100644 index 0000000..0732d54 --- /dev/null +++ b/Resources/config/provider.xml @@ -0,0 +1,18 @@ + + + + + + FOS\ElasticaBundle\Provider\ProviderRegistry + + + + + + + + + + diff --git a/Resources/config/serializer.xml b/Resources/config/serializer.xml new file mode 100644 index 0000000..8ee9646 --- /dev/null +++ b/Resources/config/serializer.xml @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/Resources/config/source.xml b/Resources/config/source.xml new file mode 100644 index 0000000..c0f085c --- /dev/null +++ b/Resources/config/source.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + diff --git a/Resources/config/transformer.xml b/Resources/config/transformer.xml new file mode 100644 index 0000000..0957152 --- /dev/null +++ b/Resources/config/transformer.xml @@ -0,0 +1,33 @@ + + + + + + FOS\ElasticaBundle\Transformer\ElasticaToModelTransformerCollection + FOS\ElasticaBundle\Transformer\ModelToElasticaAutoTransformer + FOS\ElasticaBundle\Transformer\ModelToElasticaIdentifierTransformer + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Resources/doc/cookbook/aliased-indexes.md b/Resources/doc/cookbook/aliased-indexes.md new file mode 100644 index 0000000..b9049c5 --- /dev/null +++ b/Resources/doc/cookbook/aliased-indexes.md @@ -0,0 +1,45 @@ +Aliased Indexes +=============== + +You can set up FOSElasticaBundle to use aliases for indexes which allows you to run an +index population without resetting the index currently being used by the application. + +> *Note*: When you're using an alias, resetting an individual type will still cause a +> reset for that type. + +To configure FOSElasticaBundle to use aliases for an index, set the use_alias option to +true. + +```yaml +fos_elastica: + indexes: + website: + use_alias: true +``` + +The process for setting up aliases on an existing application is slightly more complicated +because the bundle is not able to set an alias as the same name as an index. You have some +options on how to handle this: + +1) Delete the index from Elasticsearch. This option will make searching unavailable in your + application until a population has completed itself, and an alias is created. + +2) Change the index_name parameter for your index to something new, and manually alias the + current index to the new index_name, which will then be replaced when you run a repopulate. + +```yaml +fos_elastica: + indexes: + website: + use_alias: true + index_name: website_prod +``` + +```bash +$ curl -XPOST 'http://localhost:9200/_aliases' -d ' +{ + "actions" : [ + { "add" : { "index" : "website", "alias" : "website_prod" } } + ] +}' +``` diff --git a/Resources/doc/cookbook/custom-properties.md b/Resources/doc/cookbook/custom-properties.md new file mode 100644 index 0000000..1d7687e --- /dev/null +++ b/Resources/doc/cookbook/custom-properties.md @@ -0,0 +1,33 @@ +##### Custom Properties + +Since FOSElasticaBundle 3.1.0, we now dispatch an event for each transformation of an +object into an Elastica document which allows you to set custom properties on the Elastica +document for indexing. + +Set up an event listener or subscriber for +`FOS\ElasticaBundle\Event\TransformEvent::POST_TRANSFORM` to be able to inject your own +parameters. + +```php +class CustomPropertyListener implements EventSubscriberInterface +{ + private $anotherService; + + // ... + + public function addCustomProperty(TransformEvent $event) + { + $document = $event->getDocument(); + $custom = $this->anotherService->calculateCustom($event->getObject()); + + $document->set('custom', $custom); + } + + public static function getSubscribedEvents() + { + return array( + TransformEvent::POST_TRANSFORM => 'addCustomProperty', + ); + } +} +``` diff --git a/Resources/doc/cookbook/custom-repositories.md b/Resources/doc/cookbook/custom-repositories.md new file mode 100644 index 0000000..866f72d --- /dev/null +++ b/Resources/doc/cookbook/custom-repositories.md @@ -0,0 +1,76 @@ +##### Custom Repositories + +As well as the default repository you can create a custom repository for an entity and add +methods for particular searches. These need to extend `FOS\ElasticaBundle\Repository` to have +access to the finder: + +```php +find($query); + } +} +``` + +To use the custom repository specify it in the mapping for the entity: + +```yaml +fos_elastica: + clients: + default: { host: localhost, port: 9200 } + indexes: + website: + client: default + types: + user: + mappings: + # your mappings + persistence: + driver: orm + model: Application\UserBundle\Entity\User + provider: ~ + finder: ~ + repository: Acme\ElasticaBundle\SearchRepository\UserRepository +``` + +Then the custom queries will be available when using the repository returned from the manager: + +```php +/** var FOS\ElasticaBundle\Manager\RepositoryManager */ +$repositoryManager = $container->get('fos_elastica.manager'); + +/** var FOS\ElasticaBundle\Repository */ +$repository = $repositoryManager->getRepository('UserBundle:User'); + +/** var array of Acme\UserBundle\Entity\User */ +$users = $repository->findWithCustomQuery('bob'); +``` + +Alternatively you can specify the custom repository using an annotation in the entity: + +```php +userType = $userType; + } + + /** + * Insert the repository objects in the type index + * + * @param \Closure $loggerClosure + * @param array $options + */ + public function populate(\Closure $loggerClosure = null, array $options = array()) + { + if ($loggerClosure) { + $loggerClosure('Indexing users'); + } + + $document = new Document(); + $document->setData(array('username' => 'Bob')); + $this->userType->addDocuments(array($document)); + } +} +``` + +You will find a more complete implementation example in `src/FOS/ElasticaBundle/Doctrine/AbstractProvider.php`. diff --git a/Resources/doc/cookbook/multiple-connections.md b/Resources/doc/cookbook/multiple-connections.md new file mode 100644 index 0000000..9544359 --- /dev/null +++ b/Resources/doc/cookbook/multiple-connections.md @@ -0,0 +1,21 @@ +Multiple Connections +==================== + +You can define multiple endpoints for an Elastica client by specifying them as +multiple connections in the client configuration: + +```yaml +fos_elastica: + clients: + default: + connections: + - url: http://es1.example.net:9200 + - url: http://es2.example.net:9200 + connection_strategy: RoundRobin +``` + +Elastica allows for definition of different connection strategies and by default +supports `RoundRobin` and `Simple`. You can see definitions for these strategies +in the `Elastica\Connection\Strategy` namespace. + +For more information on Elastica clustering see http://elastica.io/getting-started/installation.html#section-connect-cluster diff --git a/Resources/doc/cookbook/suppress-server-errors.md b/Resources/doc/cookbook/suppress-server-errors.md new file mode 100644 index 0000000..72c7b38 --- /dev/null +++ b/Resources/doc/cookbook/suppress-server-errors.md @@ -0,0 +1,59 @@ +Suppressing Server Errors +========================= + +By default, exceptions from the Elastica client library will propagate through +the bundle's Client class. For instance, if the Elasticsearch server is offline, +issuing a request will result in an `Elastica\Exception\Connection` being thrown. +Depending on your needs, it may be desirable to suppress these exceptions and +allow searches to fail silently. + +One way to achieve this is to override the `fos_elastica.client.class` service +container parameter with a custom class. In the following example, we override +the `Client::request()` method and return the equivalent of an empty search +response if an exception occurred. + +Sample client code: +------------------- + +```php +_logger) { + $this->_logger->warning('Failed to send a request to ElasticSearch', array( + 'exception' => $e->getMessage(), + 'path' => $path, + 'method' => $method, + 'data' => $data, + 'query' => $query + )); + } + + return new Response('{"took":0,"timed_out":false,"hits":{"total":0,"max_score":0,"hits":[]}}'); + } + } +} +``` + +Configuration change: +--------------------- + +You must update a parameter in your `app/config/config.yml` file to point to your overridden client: + +```yaml +parameters: + fos_elastica.client.class: Acme\ElasticaBundle\Client +``` diff --git a/Resources/doc/index.md b/Resources/doc/index.md new file mode 100644 index 0000000..c856798 --- /dev/null +++ b/Resources/doc/index.md @@ -0,0 +1,22 @@ +FOSElasticaBundle Documentation +=============================== + +Available documentation for FOSElasticaBundle +--------------------------------------------- + +* [Setup](setup.md) +* [Usage](usage.md) +* [Using a Serializer](serializer.md) +* [Types](types.md) + +Cookbook Entries +---------------- + +* [Aliased Indexes](cookbook/aliased-indexes.md) +* [Custom Indexed Properties](cookbook/custom-properties.md) +* [Custom Repositories](cookbook/custom-repositories.md) +* [HTTP Headers for Elastica](cookbook/elastica-client-http-headers.md) +* Performance - [Logging](cookbook/logging.md) +* [Manual Providers](cookbook/manual-provider.md) +* [Clustering - Multiple Connections](cookbook/multiple-connections.md) +* [Suppressing server errors](cookbook/suppress-server-errors.md) diff --git a/Resources/doc/serializer.md b/Resources/doc/serializer.md new file mode 100644 index 0000000..05d958c --- /dev/null +++ b/Resources/doc/serializer.md @@ -0,0 +1,39 @@ +Using a Serializer in FOSElasticaBundle +======================================= + +FOSElasticaBundle supports using a Serializer component to serialize your objects to JSON +which will be sent directly to the Elasticsearch server. Combined with automatic mapping +it means types do not have to be mapped. + +A) Install and declare the serializer +------------------------- + +Follow the installation instructions for [JMSSerializerBundle](http://jmsyst.com/bundles/JMSSerializerBundle). + +Enable the serializer configuration for the bundle: + +```yaml +#app/config/config.yml +fos_elastica: + serializer: ~ +``` + +The default configuration that comes with FOSElasticaBundle supports both the JMS Serializer +and the Symfony Serializer. If JMSSerializerBundle is installed, additional support for +serialization groups and versions are added to the bundle. + +B) Set up each defined type to support serialization +---------------------------------------------------- + +A type does not need to have mappings defined when using a serializer. An example configuration +for a type in this case: + +```yaml +fos_elastica: + indexes: + search: + types: + user: + serializer: + groups: [elastica, Default] +``` diff --git a/Resources/doc/setup.md b/Resources/doc/setup.md new file mode 100644 index 0000000..2692bb6 --- /dev/null +++ b/Resources/doc/setup.md @@ -0,0 +1,156 @@ +Step 1: Setting up the bundle +============================= + +A: Download the Bundle +---------------------- + +Open a command console, enter your project directory and execute the +following command to download the latest stable version of this bundle: + +```bash +$ composer require friendsofsymfony/elastica-bundle +``` + +This command requires you to have Composer installed globally, as explained +in the [installation chapter](https://getcomposer.org/doc/00-intro.md) +of the Composer documentation. + +### Elasticsearch + +Instructions for installing and deploying Elasticsearch may be found [here](https://www.elastic.co/downloads/elasticsearch). + +Step 2: Enable the Bundle +------------------------- + +Then, enable the bundle by adding the following line in the `app/AppKernel.php` +file of your project: + +```php +indexableUsername`, and the indexed field `firstName` would be populated from a +key `first` from an array on `User->names`. + +Setting the property path to `false` will disable transformation of that value. In this +case the mapping will be created but no value will be populated while indexing. You can +populate this value by listening to the `POST_TRANSFORM` event emitted by this bundle. +See [cookbook/custom-properties.md](cookbook/custom-properties.md) for more information +about this event. + +Handling missing results with FOSElasticaBundle +----------------------------------------------- + +By default, FOSElasticaBundle will throw an exception if the results returned from +Elasticsearch are different from the results it finds from the chosen persistence +provider. This may pose problems for a large index where updates do not occur instantly +or another process has removed the results from your persistence provider without +updating Elasticsearch. + +The error you're likely to see is something like: +'Cannot find corresponding Doctrine objects for all Elastica results.' + +To solve this issue, each type can be configured to ignore the missing results: + +```yaml + user: + persistence: + elastica_to_model_transformer: + ignore_missing: true +``` + +Dynamic templates +----------------- + +Dynamic templates allow to define mapping templates that will be +applied when dynamic introduction of fields / objects happens. + +[Documentation](http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/mapping-root-object-type.html#_dynamic_templates) + +```yaml +fos_elastica: + indexes: + site: + types: + user: + dynamic_templates: + my_template_1: + match: apples_* + mapping: + type: float + my_template_2: + match: * + match_mapping_type: string + mapping: + type: string + index: not_analyzed + mappings: + username: { type: string } +``` + +Nested objects in FOSElasticaBundle +----------------------------------- + +Note that object can autodetect properties + +```yaml +fos_elastica: + indexes: + website: + types: + post: + mappings: + date: { boost: 5 } + title: { boost: 3 } + content: ~ + comments: + type: "nested" + properties: + date: { boost: 5 } + content: ~ + user: + type: "object" + approver: + type: "object" + properties: + date: { boost: 5 } +``` + +Parent fields +------------- + +```yaml +fos_elastica: + indexes: + website: + types: + comment: + mappings: + date: { boost: 5 } + content: ~ + _parent: + type: "post" + property: "post" + identifier: "id" +``` + +The parent field declaration has the following values: + + * `type`: The parent type. + * `property`: The property in the child entity where to look for the parent entity. It may be ignored if is equal to + the parent type. + * `identifier`: The property in the parent entity which has the parent identifier. Defaults to `id`. + +Note that to create a document with a parent, you need to call `setParent` on the document rather than setting a +_parent field. If you do this wrong, you will see a `RoutingMissingException` as Elasticsearch does not know where +to store a document that should have a parent but does not specify it. + +Date format example +------------------- + +If you want to specify a [date format](http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/mapping-date-format.html): + +```yaml + user: + mappings: + username: { type: string } + lastlogin: { type: date, format: basic_date_time } + birthday: { type: date, format: "yyyy-MM-dd" } +``` + +Custom settings +--------------- + +Any setting can be specified when declaring a type. For example, to enable a custom +analyzer, you could write: + +```yaml + indexes: + search: + settings: + index: + analysis: + analyzer: + my_analyzer: + type: custom + tokenizer: lowercase + filter : [my_ngram] + filter: + my_ngram: + type: "nGram" + min_gram: 3 + max_gram: 5 + types: + blog: + mappings: + title: { boost: 8, analyzer: my_analyzer } +``` + +Testing if an object should be indexed +-------------------------------------- + +FOSElasticaBundle can be configured to automatically index changes made for +different kinds of objects if your persistence backend supports these methods, +but in some cases you might want to run an external service or call a property +on the object to see if it should be indexed. + +A property, `indexable_callback` is provided under the type configuration that +lets you configure this behaviour which will apply for any automated watching +for changes and for a repopulation of an index. + +In the example below, we're checking the enabled property on the user to only +index enabled users. + +```yaml + types: + users: + indexable_callback: 'enabled' +``` + +The callback option supports multiple approaches: + +* A method on the object itself provided as a string. `enabled` will call + `Object->enabled()`. Note that this does not support chaining methods with dot notation + like property paths. To achieve something similar use the ExpressionLanguage option + below. +* An array of a service id and a method which will be called with the object as the first + and only argument. `[ @my_custom_service, 'userIndexable' ]` will call the userIndexable + method on a service defined as my_custom_service. +* An array of a class and a static method to call on that class which will be called with + the object as the only argument. `[ 'Acme\DemoBundle\IndexableChecker', 'isIndexable' ]` + will call Acme\DemoBundle\IndexableChecker::isIndexable($object) +* A single element array with a service id can be used if the service has an __invoke + method. Such an invoke method must accept a single parameter for the object to be indexed. + `[ @my_custom_invokable_service ]` +* If you have the ExpressionLanguage component installed, A valid ExpressionLanguage + expression provided as a string. The object being indexed will be supplied as `object` + in the expression. `object.isEnabled() or object.shouldBeIndexedAnyway()`. For more + information on the ExpressionLanguage component and its capabilities see its + [documentation](http://symfony.com/doc/current/components/expression_language/index.html) + +In all cases, the callback should return a true or false, with true indicating it will be +indexed, and a false indicating the object should not be indexed, or should be removed +from the index if we are running an update. + +Provider Configuration +---------------------- + +### Specifying a custom query builder for populating indexes + +When populating an index, it may be required to use a different query builder method +to define which entities should be queried. + +```yaml + user: + persistence: + provider: + query_builder_method: createIsActiveQueryBuilder +``` + +### Populating batch size + +By default, ElasticaBundle will index documents by packets of 100. +You can change this value in the provider configuration. + +```yaml + user: + persistence: + provider: + batch_size: 10 +``` + +### Changing the document identifier + +By default, ElasticaBundle will use the `id` field of your entities as +the Elasticsearch document identifier. You can change this value in the +persistence configuration. + +```yaml + user: + persistence: + identifier: searchId +``` + +### Turning on the persistence backend logger in production + +FOSElasticaBundle will turn of your persistence backend's logging configuration by default +when Symfony2 is not in debug mode. You can force FOSElasticaBundle to always disable +logging by setting debug_logging to false, to leave logging alone by setting it to true, +or leave it set to its default value which will mirror %kernel.debug%. + +```yaml + user: + persistence: + provider: + debug_logging: false +``` + +Listener Configuration +---------------------- + +### Realtime, selective index update + +If you use the Doctrine integration, you can let ElasticaBundle update the indexes automatically +when an object is added, updated or removed. It uses Doctrine lifecycle events. +Declare that you want to update the index in real time: + +```yaml + user: + persistence: + driver: orm + model: Application\UserBundle\Entity\User + listener: ~ # by default, listens to "insert", "update" and "delete" +``` + +Now the index is automatically updated each time the state of the bound Doctrine repository changes. +No need to repopulate the whole "user" index when a new `User` is created. + +You can also choose to only listen for some of the events: + +```yaml + persistence: + listener: + insert: true + update: false + delete: true +``` + +> **Propel** doesn't support this feature yet. + +Flushing Method +--------------- + +FOSElasticaBundle, since 3.0.0 performs its indexing in the postFlush Doctrine event +instead of prePersist and preUpdate which means that indexing will only occur when there +has been a successful flush. This new default makes more sense but in the instance where +you want to perform indexing before the flush is confirmed you may set the `immediate` +option on a type persistence configuration to `true`. + +```yaml + persistence: + listener: + immediate: true +``` + +Logging Errors +-------------- + +By default FOSElasticaBundle will not catch errors thrown by Elastica/ElasticSearch. +Configure a logger per listener if you would rather catch and log these. + +```yaml + persistence: + listener: + logger: true +``` + +Specifying `true` will use the default Elastica logger. Alternatively define your own +logger service id. diff --git a/Resources/doc/usage.md b/Resources/doc/usage.md new file mode 100644 index 0000000..be11dbf --- /dev/null +++ b/Resources/doc/usage.md @@ -0,0 +1,203 @@ +FOSElasticaBundle Usage +======================= + +Basic Searching with a Finder +----------------------------- + +The most useful searching method is to use a finder defined by the type configuration. +A finder will return results that have been hydrated by the configured persistence backend, +allowing you to use relationships of returned entities. For more information about +configuration options for this kind of searching, please see the [types](types.md) +documentation. + +```php +$finder = $this->container->get('fos_elastica.finder.search.user'); + +// Option 1. Returns all users who have example.net in any of their mapped fields +$results = $finder->find('example.net'); + +// Option 2. Returns a set of hybrid results that contain all Elasticsearch results +// and their transformed counterparts. Each result is an instance of a HybridResult +$results = $finder->findHybrid('example.net'); + +// Option 3a. Pagerfanta'd resultset +/** var Pagerfanta\Pagerfanta */ +$userPaginator = $finder->findPaginated('bob'); +$countOfResults = $userPaginator->getNbResults(); + +// Option 3b. KnpPaginator resultset +$paginator = $this->get('knp_paginator'); +$results = $finder->createPaginatorAdapter('bob'); +$pagination = $paginator->paginate($results, $page, 10); +``` + +Faceted Searching +----------------- + +When searching with facets, the facets can be retrieved when using the paginated +methods on the finder. + +```php +$query = new \Elastica\Query(); +$facet = new \Elastica\Facet\Terms('tags'); +$facet->setField('companyGroup'); +$query->addFacet($facet); + +$companies = $finder->findPaginated($query); +$companies->setMaxPerPage($params['limit']); +$companies->setCurrentPage($params['page']); + +$facets = $companies->getAdapter()->getFacets(); +``` + +Searching the entire index +-------------------------- + +You can also define a finder that will work on the entire index. Adjust your index +configuration as per below: + +```yaml +fos_elastica: + indexes: + website: + finder: ~ +``` + +You can now use the index wide finder service `fos_elastica.finder.website`: + +```php +/** var FOS\ElasticaBundle\Finder\MappedFinder */ +$finder = $this->container->get('fos_elastica.finder.website'); + +// Returns a mixed array of any objects mapped +$results = $finder->find('bob'); +``` + +Type Repositories +----------------- + +In the case where you need many different methods for different searching terms, it +may be better to separate methods for each type into their own dedicated repository +classes, just like Doctrine ORM's EntityRepository classes. + +The manager class that handles repositories has a service key of `fos_elastica.manager`. +The manager will default to handling ORM entities, and the configuration must be changed +for MongoDB users. + +```yaml +fos_elastica: + default_manager: mongodb +``` + +An example for using a repository: + +```php +/** var FOS\ElasticaBundle\Manager\RepositoryManager */ +$repositoryManager = $this->container->get('fos_elastica.manager'); + +/** var FOS\ElasticaBundle\Repository */ +$repository = $repositoryManager->getRepository('UserBundle:User'); + +/** var array of Acme\UserBundle\Entity\User */ +$users = $repository->find('bob'); +``` + +For more information about customising repositories, see the cookbook entry +[Custom Repositories](cookbook/custom-repositories.md). + +Using a custom query builder method for transforming results +------------------------------------------------------------ + +When returning results from Elasticsearch to be transformed by the bundle, the default +`createQueryBuilder` method on each objects Repository class will be called. In many +circumstances this is not ideal and you'd prefer to use a different method to join in +any entity relations that are required on the page that will be displaying the results. + +```yaml + user: + persistence: + elastica_to_model_transformer: + query_builder_method: createSearchQueryBuilder +``` + +An example for using a custom query builder method: + +```php +class UserRepository extends EntityRepository +{ + /** + * Used by Elastica to transform results to model + * + * @param string $entityAlias + * @return Doctrine\ORM\QueryBuilder + */ + public function createSearchQueryBuilder($entityAlias) + { + $qb = $this->createQueryBuilder($entityAlias); + + $qb->select($entityAlias, 'g') + ->innerJoin($entityAlias.'.groups', 'g'); + + return $qb; + } +} +``` + +Advanced Searching Example +-------------------------- + +If you would like to perform more advanced queries, here is one example using +the snowball stemming algorithm. + +It searches for Article entities using `title`, `tags`, and `categoryIds`. +Results must match at least one specified `categoryIds`, and should match the +`title` or `tags` criteria. Additionally, we define a snowball analyzer to +apply to queries against the `title` field. + +Assuming a type is configured as follows: + +```yaml +fos_elastica: + indexes: + site: + settings: + index: + analysis: + analyzer: + my_analyzer: + type: snowball + language: English + types: + article: + mappings: + title: { boost: 10, analyzer: my_analyzer } + tags: + categoryIds: + persistence: + driver: orm + model: Acme\DemoBundle\Entity\Article + provider: ~ + finder: ~ +``` + +The following code will execute a search against the Elasticsearch server: + +```php +$finder = $this->container->get('fos_elastica.finder.site.article'); +$boolQuery = new \Elastica\Query\Bool(); + +$fieldQuery = new \Elastica\Query\Match(); +$fieldQuery->setFieldQuery('title', 'I am a title string'); +$fieldQuery->setFieldParam('title', 'analyzer', 'my_analyzer'); +$boolQuery->addShould($fieldQuery); + +$tagsQuery = new \Elastica\Query\Terms(); +$tagsQuery->setTerms('tags', array('tag1', 'tag2')); +$boolQuery->addShould($tagsQuery); + +$categoryQuery = new \Elastica\Query\Terms(); +$categoryQuery->setTerms('categoryIds', array('1', '2', '3')); +$boolQuery->addMust($categoryQuery); + +$data = $finder->find($boolQuery); +``` diff --git a/Resources/meta/LICENSE b/Resources/meta/LICENSE index 39afd28..314bd5b 100644 --- a/Resources/meta/LICENSE +++ b/Resources/meta/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2011 Exercise.com, Inc +Copyright (c) 2011-2013 Exercise.com, Inc Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/Resources/public/images/elastica.png b/Resources/public/images/elastica.png deleted file mode 100644 index dbde014..0000000 Binary files a/Resources/public/images/elastica.png and /dev/null differ diff --git a/Resources/views/Collector/elastica.html.twig b/Resources/views/Collector/elastica.html.twig index 137dcdb..82a3dcf 100644 --- a/Resources/views/Collector/elastica.html.twig +++ b/Resources/views/Collector/elastica.html.twig @@ -4,6 +4,9 @@ {% set icon %} elastica {{ collector.querycount }} + {% if collector.querycount > 0 %} + in {{ '%0.2f'|format(collector.time * 1000) }} ms + {% endif %} {% endset %} {% set text %}
@@ -19,13 +22,14 @@ {% endblock %} {% block menu %} - - - Elastica - - {{ collector.querycount }} + + + Elastica + + {{ collector.querycount }} + {{ '%0.0f'|format(collector.time * 1000) }} ms + - {% endblock %} {% block panel %} @@ -41,18 +45,53 @@

{% else %}
    - {% for query in collector.queries %} + {% for key, query in collector.queries %}
  • Path: {{ query.path }}
    - Method: {{ query.method }} + Query: {{ query.queryString|url_encode }}
    + Method: {{ query.method }} ({{ query.connection.transport }} on {{ query.connection.host }}:{{ query.connection.port }})
    - {{ query.data|yaml_encode }} + {{ query.data|json_encode }}
    Time: {{ '%0.2f'|format(query.executionMS * 1000) }} ms + + {% if query.connection.transport in ['Http', 'Https'] %}{# cURL support only HTTP #} + + + + - + Display cURL query + + + + {% endif %}
  • {% endfor %}
+ + {% endif %} {% endblock %} diff --git a/Serializer/Callback.php b/Serializer/Callback.php new file mode 100644 index 0000000..61da997 --- /dev/null +++ b/Serializer/Callback.php @@ -0,0 +1,54 @@ +serializer = $serializer; + if (!method_exists($this->serializer, 'serialize')) { + throw new \RuntimeException('The serializer must have a "serialize" method.'); + } + } + + public function setGroups(array $groups) + { + $this->groups = $groups; + + if (!empty($this->groups) && !$this->serializer instanceof SerializerInterface) { + throw new \RuntimeException('Setting serialization groups requires using "JMS\Serializer\Serializer".'); + } + } + + public function setVersion($version) + { + $this->version = $version; + + if ($this->version && !$this->serializer instanceof SerializerInterface) { + throw new \RuntimeException('Setting serialization version requires using "JMS\Serializer\Serializer".'); + } + } + + public function serialize($object) + { + $context = $this->serializer instanceof SerializerInterface ? SerializationContext::create()->enableMaxDepthChecks() : array(); + + if (!empty($this->groups)) { + $context->setGroups($this->groups); + } + + if ($this->version) { + $context->setVersion($this->version); + } + + return $this->serializer->serialize($object, 'json', $context); + } +} diff --git a/Subscriber/PaginateElasticaQuerySubscriber.php b/Subscriber/PaginateElasticaQuerySubscriber.php index cbe508d..63f6cd0 100644 --- a/Subscriber/PaginateElasticaQuerySubscriber.php +++ b/Subscriber/PaginateElasticaQuerySubscriber.php @@ -2,6 +2,7 @@ namespace FOS\ElasticaBundle\Subscriber; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Knp\Component\Pager\Event\ItemsEvent; use FOS\ElasticaBundle\Paginator\PaginatorAdapterInterface; @@ -9,9 +10,19 @@ use FOS\ElasticaBundle\Paginator\PartialResultsInterface; class PaginateElasticaQuerySubscriber implements EventSubscriberInterface { + private $request; + + public function setRequest(Request $request = null) + { + $this->request = $request; + } + public function items(ItemsEvent $event) { if ($event->target instanceof PaginatorAdapterInterface) { + // Add sort to query + $this->setSorting($event); + /** @var $results PartialResultsInterface */ $results = $event->target->getResults($event->getOffset(), $event->getLimit()); @@ -21,15 +32,49 @@ class PaginateElasticaQuerySubscriber implements EventSubscriberInterface if (null != $facets) { $event->setCustomPaginationParameter('facets', $facets); } + $aggregations = $results->getAggregations(); + if (null != $aggregations) { + $event->setCustomPaginationParameter('aggregations', $aggregations); + } $event->stopPropagation(); } } + /** + * Adds knp paging sort to query. + * + * @param ItemsEvent $event + */ + protected function setSorting(ItemsEvent $event) + { + $options = $event->options; + $sortField = $this->request->get($options['sortFieldParameterName']); + + if (!empty($sortField)) { + // determine sort direction + $dir = 'asc'; + $sortDirection = $this->request->get($options['sortDirectionParameterName']); + if ('desc' === strtolower($sortDirection)) { + $dir = 'desc'; + } + + // check if the requested sort field is in the sort whitelist + if (isset($options['sortFieldWhitelist']) && !in_array($sortField, $options['sortFieldWhitelist'])) { + throw new \UnexpectedValueException(sprintf('Cannot sort by: [%s] this field is not in whitelist', $sortField)); + } + + // set sort on active query + $event->target->getQuery()->setSort(array( + $sortField => array('order' => $dir), + )); + } + } + public static function getSubscribedEvents() { return array( - 'knp_pager.items' => array('items', 1) + 'knp_pager.items' => array('items', 1), ); } -} \ No newline at end of file +} diff --git a/Tests/Command/ResetCommandTest.php b/Tests/Command/ResetCommandTest.php new file mode 100644 index 0000000..d63b380 --- /dev/null +++ b/Tests/Command/ResetCommandTest.php @@ -0,0 +1,90 @@ +resetter = $this->getMockBuilder('\FOS\ElasticaBundle\Resetter') + ->disableOriginalConstructor() + ->setMethods(array('resetIndex', 'resetIndexType')) + ->getMock(); + + $container->set('fos_elastica.resetter', $this->resetter); + + $this->indexManager = $this->getMockBuilder('\FOS\ElasticaBundle\IndexManager') + ->disableOriginalConstructor() + ->setMethods(array('getAllIndexes')) + ->getMock(); + + $container->set('fos_elastica.index_manager', $this->indexManager); + + $this->command = new ResetCommand(); + $this->command->setContainer($container); + } + + public function testResetAllIndexes() + { + $this->indexManager->expects($this->any()) + ->method('getAllIndexes') + ->will($this->returnValue(array('index1' => true, 'index2' => true))); + + $this->resetter->expects($this->at(0)) + ->method('resetIndex') + ->with($this->equalTo('index1')); + + $this->resetter->expects($this->at(1)) + ->method('resetIndex') + ->with($this->equalTo('index2')); + + $this->command->run( + new ArrayInput(array()), + new NullOutput() + ); + } + + public function testResetIndex() + { + $this->indexManager->expects($this->never()) + ->method('getAllIndexes'); + + $this->resetter->expects($this->at(0)) + ->method('resetIndex') + ->with($this->equalTo('index1')); + + $this->command->run( + new ArrayInput(array('--index' => 'index1')), + new NullOutput() + ); + } + + public function testResetIndexType() + { + $this->indexManager->expects($this->never()) + ->method('getAllIndexes'); + + $this->resetter->expects($this->never()) + ->method('resetIndex'); + + $this->resetter->expects($this->at(0)) + ->method('resetIndexType') + ->with($this->equalTo('index1'), $this->equalTo('type1')); + + $this->command->run( + new ArrayInput(array('--index' => 'index1', '--type' => 'type1')), + new NullOutput() + ); + } +} diff --git a/Tests/DataCollector/ElasticaDataCollectorTest.php b/Tests/DataCollector/ElasticaDataCollectorTest.php index 758e1c2..aac1fb4 100644 --- a/Tests/DataCollector/ElasticaDataCollectorTest.php +++ b/Tests/DataCollector/ElasticaDataCollectorTest.php @@ -9,7 +9,6 @@ use FOS\ElasticaBundle\DataCollector\ElasticaDataCollector; */ class ElasticaDataCollectorTest extends \PHPUnit_Framework_TestCase { - public function testCorrectAmountOfQueries() { /** @var $requestMock \PHPUnit_Framework_MockObject_MockObject|\Symfony\Component\HttpFoundation\Request */ @@ -93,5 +92,4 @@ class ElasticaDataCollectorTest extends \PHPUnit_Framework_TestCase $elasticaDataCollector->collect($requestMock, $responseMock); $this->assertEquals(30, $elasticaDataCollector->getTime()); } - } diff --git a/Tests/DependencyInjection/ConfigurationTest.php b/Tests/DependencyInjection/ConfigurationTest.php new file mode 100644 index 0000000..062db5c --- /dev/null +++ b/Tests/DependencyInjection/ConfigurationTest.php @@ -0,0 +1,265 @@ +processor = new Processor(); + } + + private function getConfigs(array $configArray) + { + $configuration = new Configuration(true); + + return $this->processor->processConfiguration($configuration, array($configArray)); + } + + public function testUnconfiguredConfiguration() + { + $configuration = $this->getConfigs(array()); + + $this->assertSame(array( + 'clients' => array(), + 'indexes' => array(), + 'default_manager' => 'orm', + ), $configuration); + } + + public function testClientConfiguration() + { + $configuration = $this->getConfigs(array( + 'clients' => array( + 'default' => array( + 'url' => 'http://localhost:9200', + ), + 'clustered' => array( + 'connections' => array( + array( + 'url' => 'http://es1:9200', + 'headers' => array( + 'Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==', + ), + ), + array( + 'url' => 'http://es2:9200', + 'headers' => array( + 'Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==', + ), + ), + ), + ), + ), + )); + + $this->assertCount(2, $configuration['clients']); + $this->assertCount(1, $configuration['clients']['default']['connections']); + $this->assertCount(0, $configuration['clients']['default']['connections'][0]['headers']); + + $this->assertCount(2, $configuration['clients']['clustered']['connections']); + $this->assertEquals('http://es2:9200/', $configuration['clients']['clustered']['connections'][1]['url']); + $this->assertCount(1, $configuration['clients']['clustered']['connections'][1]['headers']); + $this->assertEquals('Authorization: Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==', $configuration['clients']['clustered']['connections'][0]['headers'][0]); + } + + public function testLogging() + { + $configuration = $this->getConfigs(array( + 'clients' => array( + 'logging_enabled' => array( + 'url' => 'http://localhost:9200', + 'logger' => true, + ), + 'logging_disabled' => array( + 'url' => 'http://localhost:9200', + 'logger' => false, + ), + 'logging_not_mentioned' => array( + 'url' => 'http://localhost:9200', + ), + 'logging_custom' => array( + 'url' => 'http://localhost:9200', + 'logger' => 'custom.service', + ), + ), + )); + + $this->assertCount(4, $configuration['clients']); + + $this->assertEquals('fos_elastica.logger', $configuration['clients']['logging_enabled']['connections'][0]['logger']); + $this->assertFalse($configuration['clients']['logging_disabled']['connections'][0]['logger']); + $this->assertEquals('fos_elastica.logger', $configuration['clients']['logging_not_mentioned']['connections'][0]['logger']); + $this->assertEquals('custom.service', $configuration['clients']['logging_custom']['connections'][0]['logger']); + } + + public function testSlashIsAddedAtTheEndOfServerUrl() + { + $config = array( + 'clients' => array( + 'default' => array('url' => 'http://www.github.com'), + ), + ); + $configuration = $this->getConfigs($config); + + $this->assertEquals('http://www.github.com/', $configuration['clients']['default']['connections'][0]['url']); + } + + public function testTypeConfig() + { + $this->getConfigs(array( + 'clients' => array( + 'default' => array('url' => 'http://localhost:9200'), + ), + 'indexes' => array( + 'test' => array( + 'type_prototype' => array( + 'index_analyzer' => 'custom_analyzer', + 'persistence' => array( + 'identifier' => 'ID', + ), + 'serializer' => array( + 'groups' => array('Search'), + 'version' => 1, + ), + ), + 'types' => array( + 'test' => array( + 'mappings' => array( + 'title' => array(), + 'published' => array('type' => 'datetime'), + 'body' => null, + ), + 'persistence' => array( + 'listener' => array( + 'logger' => true, + ), + ), + ), + 'test2' => array( + 'mappings' => array( + 'title' => null, + 'children' => array( + 'type' => 'nested', + ), + ), + ), + ), + ), + ), + )); + } + + public function testClientConfigurationNoUrl() + { + $configuration = $this->getConfigs(array( + 'clients' => array( + 'default' => array( + 'host' => 'localhost', + 'port' => 9200, + ), + ), + )); + + $this->assertTrue(empty($configuration['clients']['default']['connections'][0]['url'])); + } + + public function testMappingsRenamedToProperties() + { + $configuration = $this->getConfigs(array( + 'clients' => array( + 'default' => array('url' => 'http://localhost:9200'), + ), + 'indexes' => array( + 'test' => array( + 'types' => array( + 'test' => array( + 'mappings' => array( + 'title' => array(), + 'published' => array('type' => 'datetime'), + 'body' => null, + ), + ), + ), + ), + ), + )); + + $this->assertCount(3, $configuration['indexes']['test']['types']['test']['properties']); + } + + public function testUnconfiguredType() + { + $configuration = $this->getConfigs(array( + 'clients' => array( + 'default' => array('url' => 'http://localhost:9200'), + ), + 'indexes' => array( + 'test' => array( + 'types' => array( + 'test' => null, + ), + ), + ), + )); + + $this->assertArrayHasKey('properties', $configuration['indexes']['test']['types']['test']); + } + + public function testNestedProperties() + { + $this->getConfigs(array( + 'clients' => array( + 'default' => array('url' => 'http://localhost:9200'), + ), + 'indexes' => array( + 'test' => array( + 'types' => array( + 'user' => array( + 'properties' => array( + 'field1' => array(), + ), + 'persistence' => array(), + ), + 'user_profile' => array( + '_parent' => array( + 'type' => 'user', + 'property' => 'owner', + ), + 'properties' => array( + 'field1' => array(), + 'field2' => array( + 'type' => 'nested', + 'properties' => array( + 'nested_field1' => array( + 'type' => 'integer', + ), + 'nested_field2' => array( + 'type' => 'object', + 'properties' => array( + 'id' => array( + 'type' => 'integer', + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + )); + } +} diff --git a/Tests/DependencyInjection/FOSElasticaExtensionTest.php b/Tests/DependencyInjection/FOSElasticaExtensionTest.php new file mode 100644 index 0000000..1bef2b6 --- /dev/null +++ b/Tests/DependencyInjection/FOSElasticaExtensionTest.php @@ -0,0 +1,32 @@ +setParameter('kernel.debug', true); + + $extension = new FOSElasticaExtension(); + + $extension->load($config, $containerBuilder); + + $this->assertTrue($containerBuilder->hasDefinition('fos_elastica.object_persister.test_index.child_field')); + + $persisterCallDefinition = $containerBuilder->getDefinition('fos_elastica.object_persister.test_index.child_field'); + + $arguments = $persisterCallDefinition->getArguments(); + $arguments = $arguments['index_3']; + + $this->assertArrayHasKey('_parent', $arguments); + $this->assertEquals('parent_field', $arguments['_parent']['type']); + } +} diff --git a/Tests/DependencyInjection/fixtures/config.yml b/Tests/DependencyInjection/fixtures/config.yml new file mode 100644 index 0000000..5528d18 --- /dev/null +++ b/Tests/DependencyInjection/fixtures/config.yml @@ -0,0 +1,21 @@ +fos_elastica: + clients: + default: + url: http://localhost:9200 + indexes: + test_index: + client: default + types: + parent_field: + mappings: + text: ~ + persistence: + driver: orm + model: foo_model + child_field: + mappings: + text: ~ + persistence: + driver: orm + model: foo_model + _parent: { type: "parent_field", property: "parent" } diff --git a/Tests/Doctrine/AbstractElasticaToModelTransformerTest.php b/Tests/Doctrine/AbstractElasticaToModelTransformerTest.php new file mode 100644 index 0000000..1185e74 --- /dev/null +++ b/Tests/Doctrine/AbstractElasticaToModelTransformerTest.php @@ -0,0 +1,62 @@ +getMock( + 'FOS\ElasticaBundle\Doctrine\ORM\ElasticaToModelTransformer', + array('findByIdentifiers'), + array($this->registry, $this->objectClass, array('ignore_missing' => true)) + ); + + $transformer->setPropertyAccessor(PropertyAccess::createPropertyAccessor()); + + $firstOrmResult = new \stdClass(); + $firstOrmResult->id = 1; + $secondOrmResult = new \stdClass(); + $secondOrmResult->id = 3; + $transformer->expects($this->once()) + ->method('findByIdentifiers') + ->with(array(1, 2, 3)) + ->willReturn(array($firstOrmResult, $secondOrmResult)); + + $firstElasticaResult = new Result(array('_id' => 1)); + $secondElasticaResult = new Result(array('_id' => 2)); + $thirdElasticaResult = new Result(array('_id' => 3)); + + $hybridResults = $transformer->hybridTransform(array($firstElasticaResult, $secondElasticaResult, $thirdElasticaResult)); + + $this->assertCount(2, $hybridResults); + $this->assertEquals($firstOrmResult, $hybridResults[0]->getTransformed()); + $this->assertEquals($firstElasticaResult, $hybridResults[0]->getResult()); + $this->assertEquals($secondOrmResult, $hybridResults[1]->getTransformed()); + $this->assertEquals($thirdElasticaResult, $hybridResults[1]->getResult()); + } + + protected function setUp() + { + $this->registry = $this->getMockBuilder('Doctrine\Common\Persistence\ManagerRegistry') + ->disableOriginalConstructor() + ->getMock(); + } +} diff --git a/Tests/Doctrine/AbstractListenerTest.php b/Tests/Doctrine/AbstractListenerTest.php index e3af609..dcaf7d6 100644 --- a/Tests/Doctrine/AbstractListenerTest.php +++ b/Tests/Doctrine/AbstractListenerTest.php @@ -3,72 +3,81 @@ namespace FOS\ElasticaBundle\Tests\Doctrine; /** + * See concrete MongoDB/ORM instances of this abstract test. + * * @author Richard Miller */ -abstract class AbstractListenerTest extends \PHPUnit_Framework_TestCase +abstract class ListenerTest extends \PHPUnit_Framework_TestCase { public function testObjectInsertedOnPersist() { - $persister = $this->getMockPersister(); - $entity = new Listener\Entity(1); + $persister = $this->getMockPersister($entity, 'index', 'type'); $eventArgs = $this->createLifecycleEventArgs($entity, $this->getMockObjectManager()); + $indexable = $this->getMockIndexable('index', 'type', $entity, true); + + $listener = $this->createListener($persister, $indexable, array('indexName' => 'index', 'typeName' => 'type')); + $listener->postPersist($eventArgs); + + $this->assertEquals($entity, current($listener->scheduledForInsertion)); $persister->expects($this->once()) - ->method('insertOne') - ->with($entity); + ->method('insertMany') + ->with($listener->scheduledForInsertion); - $listener = $this->createListener($persister, get_class($entity), array()); - $listener->postPersist($eventArgs); + $listener->postFlush($eventArgs); } - /** - * @dataProvider provideIsIndexableCallbacks - */ - public function testNonIndexableObjectNotInsertedOnPersist($isIndexableCallback) + public function testNonIndexableObjectNotInsertedOnPersist() { - $persister = $this->getMockPersister(); - - $entity = new Listener\Entity(1, false); + $entity = new Listener\Entity(1); + $persister = $this->getMockPersister($entity, 'index', 'type'); $eventArgs = $this->createLifecycleEventArgs($entity, $this->getMockObjectManager()); + $indexable = $this->getMockIndexable('index', 'type', $entity, false); + + $listener = $this->createListener($persister, $indexable, array('indexName' => 'index', 'typeName' => 'type')); + $listener->postPersist($eventArgs); + + $this->assertEmpty($listener->scheduledForInsertion); $persister->expects($this->never()) ->method('insertOne'); + $persister->expects($this->never()) + ->method('insertMany'); - $listener = $this->createListener($persister, get_class($entity), array()); - $listener->setIsIndexableCallback($isIndexableCallback); - $listener->postPersist($eventArgs); + $listener->postFlush($eventArgs); } public function testObjectReplacedOnUpdate() { - $persister = $this->getMockPersister(); - $entity = new Listener\Entity(1); + $persister = $this->getMockPersister($entity, 'index', 'type'); $eventArgs = $this->createLifecycleEventArgs($entity, $this->getMockObjectManager()); + $indexable = $this->getMockIndexable('index', 'type', $entity, true); + + $listener = $this->createListener($persister, $indexable, array('indexName' => 'index', 'typeName' => 'type')); + $listener->postUpdate($eventArgs); + + $this->assertEquals($entity, current($listener->scheduledForUpdate)); $persister->expects($this->once()) - ->method('replaceOne') - ->with($entity); - + ->method('replaceMany') + ->with(array($entity)); $persister->expects($this->never()) ->method('deleteById'); - $listener = $this->createListener($persister, get_class($entity), array()); - $listener->postUpdate($eventArgs); + $listener->postFlush($eventArgs); } - /** - * @dataProvider provideIsIndexableCallbacks - */ - public function testNonIndexableObjectRemovedOnUpdate($isIndexableCallback) + public function testNonIndexableObjectRemovedOnUpdate() { $classMetadata = $this->getMockClassMetadata(); $objectManager = $this->getMockObjectManager(); - $persister = $this->getMockPersister(); - $entity = new Listener\Entity(1, false); + $entity = new Listener\Entity(1); + $persister = $this->getMockPersister($entity, 'index', 'type'); $eventArgs = $this->createLifecycleEventArgs($entity, $objectManager); + $indexable = $this->getMockIndexable('index', 'type', $entity, false); $objectManager->expects($this->any()) ->method('getClassMetadata') @@ -80,26 +89,30 @@ abstract class AbstractListenerTest extends \PHPUnit_Framework_TestCase ->with($entity, 'id') ->will($this->returnValue($entity->getId())); + $listener = $this->createListener($persister, $indexable, array('indexName' => 'index', 'typeName' => 'type')); + $listener->postUpdate($eventArgs); + + $this->assertEmpty($listener->scheduledForUpdate); + $this->assertEquals($entity->getId(), current($listener->scheduledForDeletion)); + $persister->expects($this->never()) ->method('replaceOne'); - $persister->expects($this->once()) - ->method('deleteById') - ->with($entity->getId()); + ->method('deleteManyByIdentifiers') + ->with(array($entity->getId())); - $listener = $this->createListener($persister, get_class($entity), array()); - $listener->setIsIndexableCallback($isIndexableCallback); - $listener->postUpdate($eventArgs); + $listener->postFlush($eventArgs); } public function testObjectDeletedOnRemove() { $classMetadata = $this->getMockClassMetadata(); $objectManager = $this->getMockObjectManager(); - $persister = $this->getMockPersister(); $entity = new Listener\Entity(1); + $persister = $this->getMockPersister($entity, 'index', 'type'); $eventArgs = $this->createLifecycleEventArgs($entity, $objectManager); + $indexable = $this->getMockIndexable('index', 'type', $entity); $objectManager->expects($this->any()) ->method('getClassMetadata') @@ -111,23 +124,28 @@ abstract class AbstractListenerTest extends \PHPUnit_Framework_TestCase ->with($entity, 'id') ->will($this->returnValue($entity->getId())); - $persister->expects($this->once()) - ->method('deleteById') - ->with($entity->getId()); - - $listener = $this->createListener($persister, get_class($entity), array()); + $listener = $this->createListener($persister, $indexable, array('indexName' => 'index', 'typeName' => 'type')); $listener->preRemove($eventArgs); - $listener->postRemove($eventArgs); + + $this->assertEquals($entity->getId(), current($listener->scheduledForDeletion)); + + $persister->expects($this->once()) + ->method('deleteManyByIdentifiers') + ->with(array($entity->getId())); + + $listener->postFlush($eventArgs); } public function testObjectWithNonStandardIdentifierDeletedOnRemove() { $classMetadata = $this->getMockClassMetadata(); $objectManager = $this->getMockObjectManager(); - $persister = $this->getMockPersister(); $entity = new Listener\Entity(1); + $entity->identifier = 'foo'; + $persister = $this->getMockPersister($entity, 'index', 'type'); $eventArgs = $this->createLifecycleEventArgs($entity, $objectManager); + $indexable = $this->getMockIndexable('index', 'type', $entity); $objectManager->expects($this->any()) ->method('getClassMetadata') @@ -139,49 +157,30 @@ abstract class AbstractListenerTest extends \PHPUnit_Framework_TestCase ->with($entity, 'identifier') ->will($this->returnValue($entity->getId())); - $persister->expects($this->once()) - ->method('deleteById') - ->with($entity->getId()); - - $listener = $this->createListener($persister, get_class($entity), array(), 'identifier'); + $listener = $this->createListener($persister, $indexable, array('identifier' => 'identifier', 'indexName' => 'index', 'typeName' => 'type')); $listener->preRemove($eventArgs); - $listener->postRemove($eventArgs); - } - /** - * @dataProvider provideInvalidIsIndexableCallbacks - * @expectedException \RuntimeException - */ - public function testInvalidIsIndexableCallbacks($isIndexableCallback) - { - $listener = $this->createListener($this->getMockPersister(), 'FOS\ElasticaBundle\Tests\Doctrine\Listener\Entity', array()); - $listener->setIsIndexableCallback($isIndexableCallback); - } + $this->assertEquals($entity->identifier, current($listener->scheduledForDeletion)); - public function provideInvalidIsIndexableCallbacks() - { - return array( - array('nonexistentEntityMethod'), - array(array(new Listener\IndexableDecider(), 'internalMethod')), - array(42), - ); - } + $persister->expects($this->once()) + ->method('deleteManyByIdentifiers') + ->with(array($entity->identifier)); - public function provideIsIndexableCallbacks() - { - return array( - array('getIsIndexable'), - array(array(new Listener\IndexableDecider(), 'isIndexable')), - array(function(Listener\Entity $entity) { return $entity->getIsIndexable(); }), - ); + $listener->postFlush($eventArgs); } abstract protected function getLifecycleEventArgsClass(); abstract protected function getListenerClass(); + /** + * @return string + */ abstract protected function getObjectManagerClass(); + /** + * @return string + */ abstract protected function getClassMetadataClass(); private function createLifecycleEventArgs() @@ -212,9 +211,59 @@ abstract class AbstractListenerTest extends \PHPUnit_Framework_TestCase ->getMock(); } - private function getMockPersister() + /** + * @param Listener\Entity $object + * @param string $indexName + * @param string $typeName + */ + private function getMockPersister($object, $indexName, $typeName) { - return $this->getMock('FOS\ElasticaBundle\Persister\ObjectPersisterInterface'); + $mock = $this->getMockBuilder('FOS\ElasticaBundle\Persister\ObjectPersister') + ->disableOriginalConstructor() + ->getMock(); + + $mock->expects($this->any()) + ->method('handlesObject') + ->with($object) + ->will($this->returnValue(true)); + + $index = $this->getMockBuilder('Elastica\Index')->disableOriginalConstructor()->getMock(); + $index->expects($this->any()) + ->method('getName') + ->will($this->returnValue($indexName)); + $type = $this->getMockBuilder('Elastica\Type')->disableOriginalConstructor()->getMock(); + $type->expects($this->any()) + ->method('getName') + ->will($this->returnValue($typeName)); + $type->expects($this->any()) + ->method('getIndex') + ->will($this->returnValue($index)); + + $mock->expects($this->any()) + ->method('getType') + ->will($this->returnValue($type)); + + return $mock; + } + + /** + * @param string $indexName + * @param string $typeName + * @param Listener\Entity $object + * @param boolean $return + */ + private function getMockIndexable($indexName, $typeName, $object, $return = null) + { + $mock = $this->getMock('FOS\ElasticaBundle\Provider\IndexableInterface'); + + if (null !== $return) { + $mock->expects($this->once()) + ->method('isObjectIndexable') + ->with($indexName, $typeName, $object) + ->will($this->returnValue($return)); + } + + return $mock; } } @@ -223,33 +272,18 @@ namespace FOS\ElasticaBundle\Tests\Doctrine\Listener; class Entity { private $id; - private $isIndexable; + public $identifier; - public function __construct($id, $isIndexable = true) + /** + * @param integer $id + */ + public function __construct($id) { $this->id = $id; - $this->isIndexable = $isIndexable; } public function getId() { return $this->id; } - - public function getIsIndexable() - { - return $this->isIndexable; - } -} - -class IndexableDecider -{ - public function isIndexable(Entity $entity) - { - return $entity->getIsIndexable(); - } - - protected function internalMethod() - { - } } diff --git a/Tests/Doctrine/AbstractProviderTest.php b/Tests/Doctrine/AbstractProviderTest.php index 0a9aceb..aa28a4c 100644 --- a/Tests/Doctrine/AbstractProviderTest.php +++ b/Tests/Doctrine/AbstractProviderTest.php @@ -2,6 +2,9 @@ namespace FOS\ElasticaBundle\Tests\Doctrine; +use Elastica\Bulk\ResponseSet; +use Elastica\Response; + class AbstractProviderTest extends \PHPUnit_Framework_TestCase { private $objectClass; @@ -9,24 +12,25 @@ class AbstractProviderTest extends \PHPUnit_Framework_TestCase private $objectPersister; private $options; private $managerRegistry; + private $indexable; + private $sliceFetcher; public function setUp() { - if (!interface_exists('Doctrine\Common\Persistence\ManagerRegistry')) { - $this->markTestSkipped('Doctrine Common is not available.'); - } + $this->objectClass = 'objectClass'; + $this->options = array('debug_logging' => true, 'indexName' => 'index', 'typeName' => 'type'); - $this->objectClass = 'objectClass'; - $this->options = array(); + $this->objectPersister = $this->getMockObjectPersister(); + $this->managerRegistry = $this->getMockManagerRegistry(); + $this->objectManager = $this->getMockObjectManager(); + $this->indexable = $this->getMockIndexable(); - $this->objectPersister = $this->getMockObjectPersister(); - $this->managerRegistry = $this->getMockManagerRegistry(); - $this->objectManager = $this->getMockObjectManager(); + $this->managerRegistry->expects($this->any()) + ->method('getManagerForClass') + ->with($this->objectClass) + ->will($this->returnValue($this->objectManager)); - $this->managerRegistry->expects($this->any()) - ->method('getManagerForClass') - ->with($this->objectClass) - ->will($this->returnValue($this->objectManager)); + $this->sliceFetcher = $this->getMockSliceFetcher(); } /** @@ -49,6 +53,58 @@ class AbstractProviderTest extends \PHPUnit_Framework_TestCase ->with($queryBuilder) ->will($this->returnValue($nbObjects)); + $this->indexable->expects($this->any()) + ->method('isObjectIndexable') + ->with('index', 'type', $this->anything()) + ->will($this->returnValue(true)); + + $previousSlice = array(); + + foreach ($objectsByIteration as $i => $objects) { + $offset = $objects[0] - 1; + + $this->sliceFetcher->expects($this->at($i)) + ->method('fetch') + ->with($queryBuilder, $batchSize, $offset, $previousSlice, array('id')) + ->will($this->returnValue($objects)); + + $this->objectManager->expects($this->at($i)) + ->method('clear'); + + $previousSlice = $objects; + } + + $this->objectPersister->expects($this->exactly(count($objectsByIteration))) + ->method('insertMany'); + + $provider->populate(); + } + + /** + * @dataProvider providePopulateIterations + */ + public function testPopulateIterationsWithoutSliceFetcher($nbObjects, $objectsByIteration, $batchSize) + { + $this->options['batch_size'] = $batchSize; + + $provider = $this->getMockAbstractProvider(false); + + $queryBuilder = new \stdClass(); + + $provider->expects($this->once()) + ->method('createQueryBuilder') + ->will($this->returnValue($queryBuilder)); + + $provider->expects($this->once()) + ->method('countObjects') + ->with($queryBuilder) + ->will($this->returnValue($nbObjects)); + + $this->indexable->expects($this->any()) + ->method('isObjectIndexable') + ->with('index', 'type', $this->anything()) + ->will($this->returnValue(true)); + $providerInvocationOffset = 2; foreach ($objectsByIteration as $i => $objects) { @@ -59,14 +115,13 @@ class AbstractProviderTest extends \PHPUnit_Framework_TestCase ->with($queryBuilder, $batchSize, $offset) ->will($this->returnValue($objects)); - $this->objectPersister->expects($this->at($i)) - ->method('insertMany') - ->with($objects); - $this->objectManager->expects($this->at($i)) ->method('clear'); } + $this->objectPersister->expects($this->exactly(count($objectsByIteration))) + ->method('insertMany'); + $provider->populate(); } @@ -75,7 +130,7 @@ class AbstractProviderTest extends \PHPUnit_Framework_TestCase return array( array( 100, - array(range(1,100)), + array(range(1, 100)), 100, ), array( @@ -98,16 +153,47 @@ class AbstractProviderTest extends \PHPUnit_Framework_TestCase ->method('countObjects') ->will($this->returnValue($nbObjects)); - $provider->expects($this->any()) - ->method('fetchSlice') + $this->sliceFetcher->expects($this->any()) + ->method('fetch') ->will($this->returnValue($objects)); + $this->indexable->expects($this->any()) + ->method('isObjectIndexable') + ->with('index', 'type', $this->anything()) + ->will($this->returnValue(true)); + $this->objectManager->expects($this->never()) ->method('clear'); $provider->populate(); } + public function testPopulateShouldClearObjectManagerForFilteredBatch() + { + $nbObjects = 1; + $objects = array(1); + + $provider = $this->getMockAbstractProvider(true); + + $provider->expects($this->any()) + ->method('countObjects') + ->will($this->returnValue($nbObjects)); + + $this->sliceFetcher->expects($this->any()) + ->method('fetch') + ->will($this->returnValue($objects)); + + $this->indexable->expects($this->any()) + ->method('isObjectIndexable') + ->with('index', 'type', $this->anything()) + ->will($this->returnValue(false)); + + $this->objectManager->expects($this->once()) + ->method('clear'); + + $provider->populate(); + } + public function testPopulateInvokesLoggerClosure() { $nbObjects = 1; @@ -119,10 +205,15 @@ class AbstractProviderTest extends \PHPUnit_Framework_TestCase ->method('countObjects') ->will($this->returnValue($nbObjects)); - $provider->expects($this->any()) - ->method('fetchSlice') + $this->sliceFetcher->expects($this->any()) + ->method('fetch') ->will($this->returnValue($objects)); + $this->indexable->expects($this->any()) + ->method('isObjectIndexable') + ->with('index', 'type', $this->anything()) + ->will($this->returnValue(true)); + $loggerClosureInvoked = false; $loggerClosure = function () use (&$loggerClosureInvoked) { $loggerClosureInvoked = true; @@ -135,16 +226,89 @@ class AbstractProviderTest extends \PHPUnit_Framework_TestCase $this->assertTrue($loggerClosureInvoked); } + public function testPopulateNotStopOnError() + { + $nbObjects = 1; + $objects = array(1); + + $provider = $this->getMockAbstractProvider(); + + $provider->expects($this->any()) + ->method('countObjects') + ->will($this->returnValue($nbObjects)); + + $this->sliceFetcher->expects($this->any()) + ->method('fetch') + ->will($this->returnValue($objects)); + + $this->indexable->expects($this->any()) + ->method('isObjectIndexable') + ->with('index', 'type', $this->anything()) + ->will($this->returnValue(true)); + + $this->objectPersister->expects($this->any()) + ->method('insertMany') + ->will($this->throwException($this->getMockBulkResponseException())); + + $this->setExpectedException('Elastica\Exception\Bulk\ResponseException'); + + $provider->populate(null, array('ignore_errors' => false)); + } + + public function testPopulateRunsIndexCallable() + { + $nbObjects = 2; + $objects = array(1, 2); + + $provider = $this->getMockAbstractProvider(); + $provider->expects($this->any()) + ->method('countObjects') + ->will($this->returnValue($nbObjects)); + + $this->sliceFetcher->expects($this->any()) + ->method('fetch') + ->will($this->returnValue($objects)); + + $this->indexable->expects($this->at(0)) + ->method('isObjectIndexable') + ->with('index', 'type', 1) + ->will($this->returnValue(false)); + $this->indexable->expects($this->at(1)) + ->method('isObjectIndexable') + ->with('index', 'type', 2) + ->will($this->returnValue(true)); + + $this->objectPersister->expects($this->once()) + ->method('insertMany') + ->with(array(2)); + + $provider->populate(); + } + /** + * @param boolean $setSliceFetcher Whether or not to set the slice fetcher. + * * @return \FOS\ElasticaBundle\Doctrine\AbstractProvider|\PHPUnit_Framework_MockObject_MockObject */ - private function getMockAbstractProvider() + private function getMockAbstractProvider($setSliceFetcher = true) { return $this->getMockForAbstractClass('FOS\ElasticaBundle\Doctrine\AbstractProvider', array( $this->objectPersister, + $this->indexable, $this->objectClass, $this->options, $this->managerRegistry, + $setSliceFetcher ? $this->sliceFetcher : null + )); + } + + /** + * @return \Elastica\Exception\Bulk\ResponseException + */ + private function getMockBulkResponseException() + { + return $this->getMock('Elastica\Exception\Bulk\ResponseException', null, array( + new ResponseSet(new Response(array()), array()), )); } @@ -161,7 +325,17 @@ class AbstractProviderTest extends \PHPUnit_Framework_TestCase */ private function getMockObjectManager() { - return $this->getMock(__NAMESPACE__ . '\ObjectManager'); + $mock = $this->getMock(__NAMESPACE__.'\ObjectManager'); + + $mock->expects($this->any()) + ->method('getClassMetadata') + ->will($this->returnSelf()); + + $mock->expects($this->any()) + ->method('getIdentifierFieldNames') + ->will($this->returnValue(array('id'))); + + return $mock; } /** @@ -171,6 +345,22 @@ class AbstractProviderTest extends \PHPUnit_Framework_TestCase { return $this->getMock('FOS\ElasticaBundle\Persister\ObjectPersisterInterface'); } + + /** + * @return \FOS\ElasticaBundle\Provider\IndexableInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private function getMockIndexable() + { + return $this->getMock('FOS\ElasticaBundle\Provider\IndexableInterface'); + } + + /** + * @return \FOS\ElasticaBundle\Doctrine\SliceFetcherInterface|\PHPUnit_Framework_MockObject_MockObject + */ + private function getMockSliceFetcher() + { + return $this->getMock('FOS\ElasticaBundle\Doctrine\SliceFetcherInterface'); + } } /** @@ -179,5 +369,7 @@ class AbstractProviderTest extends \PHPUnit_Framework_TestCase */ interface ObjectManager { - function clear(); + public function clear(); + public function getClassMetadata(); + public function getIdentifierFieldNames(); } diff --git a/Tests/Doctrine/MongoDB/ListenerTest.php b/Tests/Doctrine/MongoDB/ListenerTest.php index 7f1a9ab..37a0203 100644 --- a/Tests/Doctrine/MongoDB/ListenerTest.php +++ b/Tests/Doctrine/MongoDB/ListenerTest.php @@ -2,9 +2,9 @@ namespace FOS\ElasticaBundle\Tests\Doctrine\MongoDB; -use FOS\ElasticaBundle\Tests\Doctrine\AbstractListenerTest; +use FOS\ElasticaBundle\Tests\Doctrine\ListenerTest as BaseListenerTest; -class ListenerTest extends AbstractListenerTest +class ListenerTest extends BaseListenerTest { public function setUp() { @@ -25,7 +25,7 @@ class ListenerTest extends AbstractListenerTest protected function getListenerClass() { - return 'FOS\ElasticaBundle\Doctrine\MongoDB\Listener'; + return 'FOS\ElasticaBundle\Doctrine\Listener'; } protected function getObjectManagerClass() diff --git a/Tests/Doctrine/ORM/ElasticaToModelTransformerTest.php b/Tests/Doctrine/ORM/ElasticaToModelTransformerTest.php new file mode 100644 index 0000000..607aeef --- /dev/null +++ b/Tests/Doctrine/ORM/ElasticaToModelTransformerTest.php @@ -0,0 +1,113 @@ +getMockBuilder('Doctrine\ORM\QueryBuilder') + ->disableOriginalConstructor() + ->getMock(); + + $this->repository->expects($this->once()) + ->method('customQueryBuilderCreator') + ->with($this->equalTo(ElasticaToModelTransformer::ENTITY_ALIAS)) + ->will($this->returnValue($qb)); + $this->repository->expects($this->never()) + ->method('createQueryBuilder'); + + $transformer = new ElasticaToModelTransformer($this->registry, $this->objectClass, array( + 'query_builder_method' => 'customQueryBuilderCreator', + )); + + $class = new \ReflectionClass('FOS\ElasticaBundle\Doctrine\ORM\ElasticaToModelTransformer'); + $method = $class->getMethod('getEntityQueryBuilder'); + $method->setAccessible(true); + + $method->invokeArgs($transformer, array()); + } + + /** + * Tests that the Transformer uses the query_builder_method configuration option + * allowing configuration of createQueryBuilder call. + */ + public function testTransformUsesDefaultQueryBuilderMethodConfiguration() + { + $qb = $this->getMockBuilder('Doctrine\ORM\QueryBuilder') + ->disableOriginalConstructor() + ->getMock(); + + $this->repository->expects($this->never()) + ->method('customQueryBuilderCreator'); + $this->repository->expects($this->once()) + ->method('createQueryBuilder') + ->with($this->equalTo(ElasticaToModelTransformer::ENTITY_ALIAS)) + ->will($this->returnValue($qb)); + + $transformer = new ElasticaToModelTransformer($this->registry, $this->objectClass); + + $class = new \ReflectionClass('FOS\ElasticaBundle\Doctrine\ORM\ElasticaToModelTransformer'); + $method = $class->getMethod('getEntityQueryBuilder'); + $method->setAccessible(true); + + $method->invokeArgs($transformer, array()); + } + + protected function setUp() + { + $this->registry = $this->getMockBuilder('Doctrine\Common\Persistence\ManagerRegistry') + ->disableOriginalConstructor() + ->getMock(); + + $this->manager = $this->getMockBuilder('Doctrine\ORM\EntityManager') + ->disableOriginalConstructor() + ->getMock(); + + $this->registry->expects($this->any()) + ->method('getManagerForClass') + ->with($this->objectClass) + ->will($this->returnValue($this->manager)); + + $this->repository = $this->getMock('Doctrine\Common\Persistence\ObjectRepository', array( + 'customQueryBuilderCreator', + 'createQueryBuilder', + 'find', + 'findAll', + 'findBy', + 'findOneBy', + 'getClassName', + )); + + $this->manager->expects($this->any()) + ->method('getRepository') + ->with($this->objectClass) + ->will($this->returnValue($this->repository)); + } +} diff --git a/Tests/Doctrine/ORM/ListenerTest.php b/Tests/Doctrine/ORM/ListenerTest.php index 48702c0..36cacc6 100644 --- a/Tests/Doctrine/ORM/ListenerTest.php +++ b/Tests/Doctrine/ORM/ListenerTest.php @@ -2,17 +2,10 @@ namespace FOS\ElasticaBundle\Tests\Doctrine\ORM; -use FOS\ElasticaBundle\Tests\Doctrine\AbstractListenerTest; +use FOS\ElasticaBundle\Tests\Doctrine\ListenerTest as BaseListenerTest; -class ListenerTest extends AbstractListenerTest +class ListenerTest extends BaseListenerTest { - public function setUp() - { - if (!class_exists('Doctrine\ORM\EntityManager')) { - $this->markTestSkipped('Doctrine ORM is not available.'); - } - } - protected function getClassMetadataClass() { return 'Doctrine\ORM\Mapping\ClassMetadata'; @@ -25,7 +18,7 @@ class ListenerTest extends AbstractListenerTest protected function getListenerClass() { - return 'FOS\ElasticaBundle\Doctrine\ORM\Listener'; + return 'FOS\ElasticaBundle\Doctrine\Listener'; } protected function getObjectManagerClass() diff --git a/Tests/Doctrine/RepositoryManagerTest.php b/Tests/Doctrine/RepositoryManagerTest.php index 2863127..39f9c34 100644 --- a/Tests/Doctrine/RepositoryManagerTest.php +++ b/Tests/Doctrine/RepositoryManagerTest.php @@ -4,22 +4,19 @@ namespace FOS\ElasticaBundle\Tests\Doctrine; use FOS\ElasticaBundle\Doctrine\RepositoryManager; -class CustomRepository{} +class CustomRepository +{ +} -class Entity{} +class Entity +{ +} /** * @author Richard Miller */ class RepositoryManagerTest extends \PHPUnit_Framework_TestCase { - public function setUp() - { - if (!interface_exists('Doctrine\Common\Persistence\ManagerRegistry')) { - $this->markTestSkipped('Doctrine Common is not available.'); - } - } - public function testThatGetRepositoryReturnsDefaultRepository() { /** @var $finderMock \PHPUnit_Framework_MockObject_MockObject|\FOS\ElasticaBundle\Finder\TransformedFinder */ @@ -156,5 +153,4 @@ class RepositoryManagerTest extends \PHPUnit_Framework_TestCase $repository = $manager->getRepository($shortEntityName); $this->assertInstanceOf('FOS\ElasticaBundle\Repository', $repository); } - } diff --git a/Tests/Elastica/ClientTest.php b/Tests/Elastica/ClientTest.php new file mode 100644 index 0000000..158b553 --- /dev/null +++ b/Tests/Elastica/ClientTest.php @@ -0,0 +1,43 @@ +getMock('Elastica\Connection'); + $connection->expects($this->any())->method('getTransportObject')->will($this->returnValue($transport)); + $connection->expects($this->any())->method('toArray')->will($this->returnValue(array())); + + $logger = $this->getMock('FOS\ElasticaBundle\Logger\ElasticaLogger'); + $logger + ->expects($this->once()) + ->method('logQuery') + ->with( + 'foo', + Request::GET, + $this->isType('array'), + $this->isType('float'), + $this->isType('array'), + $this->isType('array') + ); + + $client = $this->getMockBuilder('FOS\ElasticaBundle\Elastica\Client') + ->setMethods(array('getConnection')) + ->getMock(); + + $client->expects($this->any())->method('getConnection')->will($this->returnValue($connection)); + + $client->setLogger($logger); + + $response = $client->request('foo'); + + $this->assertInstanceOf('Elastica\Response', $response); + } +} diff --git a/Tests/FOSElasticaBundleTest.php b/Tests/FOSElasticaBundleTest.php new file mode 100644 index 0000000..c9513db --- /dev/null +++ b/Tests/FOSElasticaBundleTest.php @@ -0,0 +1,22 @@ +getMock('Symfony\Component\DependencyInjection\ContainerBuilder'); + + $container + ->expects($this->atLeastOnce()) + ->method('addCompilerPass') + ->with($this->isInstanceOf('Symfony\\Component\\DependencyInjection\\Compiler\\CompilerPassInterface')); + + $bundle = new FOSElasticaBundle(); + $bundle->build($container); + } +} diff --git a/Tests/Functional/ClientTest.php b/Tests/Functional/ClientTest.php new file mode 100644 index 0000000..8a6357a --- /dev/null +++ b/Tests/Functional/ClientTest.php @@ -0,0 +1,48 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace FOS\ElasticaBundle\Tests\Functional; + +use Symfony\Bundle\FrameworkBundle\Client; + +/** + * @group functional + */ +class ClientTest extends WebTestCase +{ + public function testContainerSource() + { + $client = $this->createClient(array('test_case' => 'Basic')); + + $es = $client->getContainer()->get('fos_elastica.client.default'); + $this->assertInstanceOf('Elastica\\Connection\\Strategy\\RoundRobin', $es->getConnectionStrategy()); + + $es = $client->getContainer()->get('fos_elastica.client.second_server'); + $this->assertInstanceOf('Elastica\\Connection\\Strategy\\RoundRobin', $es->getConnectionStrategy()); + + $es = $client->getContainer()->get('fos_elastica.client.third'); + $this->assertInstanceOf('Elastica\\Connection\\Strategy\\Simple', $es->getConnectionStrategy()); + } + + protected function setUp() + { + parent::setUp(); + + $this->deleteTmpDir('Basic'); + } + + protected function tearDown() + { + parent::tearDown(); + + $this->deleteTmpDir('Basic'); + } +} diff --git a/Tests/Functional/ConfigurationManagerTest.php b/Tests/Functional/ConfigurationManagerTest.php new file mode 100644 index 0000000..a6028b7 --- /dev/null +++ b/Tests/Functional/ConfigurationManagerTest.php @@ -0,0 +1,59 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace FOS\ElasticaBundle\Tests\Functional; + +use Symfony\Bundle\FrameworkBundle\Client; + +/** + * @group functional + */ +class ConfigurationManagerTest extends WebTestCase +{ + public function testContainerSource() + { + $client = $this->createClient(array('test_case' => 'Basic')); + $manager = $this->getManager($client); + + $index = $manager->getIndexConfiguration('index'); + + $this->assertEquals('index', $index->getName()); + $this->assertGreaterThanOrEqual(2, count($index->getTypes())); + $this->assertInstanceOf('FOS\\ElasticaBundle\\Configuration\\TypeConfig', $index->getType('type')); + $this->assertInstanceOf('FOS\\ElasticaBundle\\Configuration\\TypeConfig', $index->getType('parent')); + } + + protected function setUp() + { + parent::setUp(); + + $this->deleteTmpDir('Basic'); + } + + protected function tearDown() + { + parent::tearDown(); + + $this->deleteTmpDir('Basic'); + } + + /** + * @param Client $client + * + * @return \FOS\ElasticaBundle\Configuration\ConfigManager + */ + private function getManager(Client $client) + { + $manager = $client->getContainer()->get('fos_elastica.config_manager'); + + return $manager; + } +} diff --git a/Tests/Functional/IndexableCallbackTest.php b/Tests/Functional/IndexableCallbackTest.php new file mode 100644 index 0000000..3f84286 --- /dev/null +++ b/Tests/Functional/IndexableCallbackTest.php @@ -0,0 +1,52 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace FOS\ElasticaBundle\Tests\Functional; + +/** + * @group functional + */ +class IndexableCallbackTest extends WebTestCase +{ + /** + * 2 reasons for this test:. + * + * 1) To test that the configuration rename from is_indexable_callback under the listener + * key is respected, and + * 2) To test the Extension's set up of the Indexable service. + */ + public function testIndexableCallback() + { + $client = $this->createClient(array('test_case' => 'ORM')); + + /** @var \FOS\ElasticaBundle\Provider\Indexable $in */ + $in = $client->getContainer()->get('fos_elastica.indexable'); + + $this->assertTrue($in->isObjectIndexable('index', 'type', new TypeObj())); + $this->assertTrue($in->isObjectIndexable('index', 'type2', new TypeObj())); + $this->assertFalse($in->isObjectIndexable('index', 'type3', new TypeObj())); + $this->assertFalse($in->isObjectIndexable('index', 'type4', new TypeObj())); + } + + protected function setUp() + { + parent::setUp(); + + $this->deleteTmpDir('ORM'); + } + + protected function tearDown() + { + parent::tearDown(); + + $this->deleteTmpDir('ORM'); + } +} diff --git a/Tests/Functional/MappingToElasticaTest.php b/Tests/Functional/MappingToElasticaTest.php new file mode 100644 index 0000000..6f93b7e --- /dev/null +++ b/Tests/Functional/MappingToElasticaTest.php @@ -0,0 +1,140 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace FOS\ElasticaBundle\Tests\Functional; + +use Symfony\Bundle\FrameworkBundle\Client; + +/** + * @group functional + */ +class MappingToElasticaTest extends WebTestCase +{ + public function testResetIndexAddsMappings() + { + $client = $this->createClient(array('test_case' => 'Basic')); + $resetter = $this->getResetter($client); + $resetter->resetIndex('index'); + + $type = $this->getType($client); + $mapping = $type->getMapping(); + + $this->assertNotEmpty($mapping, 'Mapping was populated'); + $this->assertArrayHasKey('store', $mapping['type']['properties']['field1']); + $this->assertTrue($mapping['type']['properties']['field1']['store']); + $this->assertArrayNotHasKey('store', $mapping['type']['properties']['field2']); + + $type = $this->getType($client, 'type'); + $mapping = $type->getMapping(); + $this->assertEquals('parent', $mapping['type']['_parent']['type']); + + $parent = $this->getType($client, 'parent'); + $mapping = $parent->getMapping(); + + $this->assertEquals('my_analyzer', $mapping['parent']['index_analyzer']); + $this->assertEquals('whitespace', $mapping['parent']['search_analyzer']); + } + + public function testResetType() + { + $client = $this->createClient(array('test_case' => 'Basic')); + $resetter = $this->getResetter($client); + $resetter->resetIndexType('index', 'type'); + + $type = $this->getType($client); + $mapping = $type->getMapping(); + + $this->assertNotEmpty($mapping, 'Mapping was populated'); + $this->assertFalse($mapping['type']['date_detection']); + $this->assertTrue($mapping['type']['numeric_detection']); + $this->assertEquals(array('yyyy-MM-dd'), $mapping['type']['dynamic_date_formats']); + $this->assertArrayHasKey('store', $mapping['type']['properties']['field1']); + $this->assertTrue($mapping['type']['properties']['field1']['store']); + $this->assertArrayNotHasKey('store', $mapping['type']['properties']['field2']); + } + + public function testORMResetIndexAddsMappings() + { + $client = $this->createClient(array('test_case' => 'ORM')); + $resetter = $this->getResetter($client); + $resetter->resetIndex('index'); + + $type = $this->getType($client); + $mapping = $type->getMapping(); + + $this->assertNotEmpty($mapping, 'Mapping was populated'); + } + + public function testORMResetType() + { + $client = $this->createClient(array('test_case' => 'ORM')); + $resetter = $this->getResetter($client); + $resetter->resetIndexType('index', 'type'); + + $type = $this->getType($client); + $mapping = $type->getMapping(); + + $this->assertNotEmpty($mapping, 'Mapping was populated'); + } + + public function testMappingIteratorToArrayField() + { + $client = $this->createClient(array('test_case' => 'ORM')); + $persister = $client->getContainer()->get('fos_elastica.object_persister.index.type'); + + $object = new TypeObj(); + $object->id = 1; + $object->coll = new \ArrayIterator(array('foo', 'bar')); + $persister->insertOne($object); + + $object->coll = new \ArrayIterator(array('foo', 'bar', 'bazz')); + $object->coll->offsetUnset(1); + + $persister->replaceOne($object); + } + + /** + * @param Client $client + * + * @return \FOS\ElasticaBundle\Resetter $resetter + */ + private function getResetter(Client $client) + { + return $client->getContainer()->get('fos_elastica.resetter'); + } + + /** + * @param Client $client + * @param string $type + * + * @return \Elastica\Type + */ + private function getType(Client $client, $type = 'type') + { + return $client->getContainer()->get('fos_elastica.index.index.'.$type); + } + + protected function setUp() + { + parent::setUp(); + + $this->deleteTmpDir('Basic'); + $this->deleteTmpDir('ORM'); + } + + protected function tearDown() + { + parent::tearDown(); + + $this->deleteTmpDir('Basic'); + $this->deleteTmpDir('ORM'); + } +} diff --git a/Tests/Functional/PropertyPathTest.php b/Tests/Functional/PropertyPathTest.php new file mode 100644 index 0000000..860cb86 --- /dev/null +++ b/Tests/Functional/PropertyPathTest.php @@ -0,0 +1,54 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace FOS\ElasticaBundle\Tests\Functional; + +use Elastica\Query\Match; + +/** + * @group functional + */ +class PropertyPathTest extends WebTestCase +{ + public function testContainerSource() + { + $client = $this->createClient(array('test_case' => 'ORM')); + /** @var \FOS\ElasticaBundle\Persister\ObjectPersister $persister */ + $persister = $client->getContainer()->get('fos_elastica.object_persister.index.property_paths_type'); + $obj = new TypeObj(); + $obj->coll = 'Hello'; + $persister->insertOne($obj); + + /** @var \Elastica\Index $elClient */ + $index = $client->getContainer()->get('fos_elastica.index.index'); + $index->flush(true); + + $query = new Match(); + $query->setField('something', 'Hello'); + $search = $index->createSearch($query); + + $this->assertEquals(1, $search->count()); + } + + protected function setUp() + { + parent::setUp(); + + $this->deleteTmpDir('Basic'); + } + + protected function tearDown() + { + parent::tearDown(); + + $this->deleteTmpDir('Basic'); + } +} diff --git a/Tests/Functional/SerializerTest.php b/Tests/Functional/SerializerTest.php new file mode 100644 index 0000000..81fbc8f --- /dev/null +++ b/Tests/Functional/SerializerTest.php @@ -0,0 +1,55 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace FOS\ElasticaBundle\Tests\Functional; + +/** + * @group functional + */ +class SerializerTest extends WebTestCase +{ + public function testMappingIteratorToArrayField() + { + $client = $this->createClient(array('test_case' => 'Serializer')); + $persister = $client->getContainer()->get('fos_elastica.object_persister.index.type'); + + $object = new TypeObj(); + $object->id = 1; + $object->coll = new \ArrayIterator(array('foo', 'bar')); + $persister->insertOne($object); + + $object->coll = new \ArrayIterator(array('foo', 'bar', 'bazz')); + $object->coll->offsetUnset(1); + + $persister->replaceOne($object); + } + + public function testUnmappedType() + { + $client = $this->createClient(array('test_case' => 'Serializer')); + $resetter = $client->getContainer()->get('fos_elastica.resetter'); + $resetter->resetIndex('index'); + } + + protected function setUp() + { + parent::setUp(); + + $this->deleteTmpDir('Serializer'); + } + + protected function tearDown() + { + parent::tearDown(); + + $this->deleteTmpDir('Serializer'); + } +} diff --git a/Tests/Functional/TypeObj.php b/Tests/Functional/TypeObj.php new file mode 100644 index 0000000..39e9fe9 --- /dev/null +++ b/Tests/Functional/TypeObj.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Tests\Functional; + +class TypeObj +{ + public $id = 5; + public $coll; + public $field1; + public $field2; + + public function isIndexable() + { + return true; + } + + public function isntIndexable() + { + return false; + } + + public function getSerializableColl() + { + return iterator_to_array($this->coll, false); + } +} diff --git a/Tests/Functional/WebTestCase.php b/Tests/Functional/WebTestCase.php new file mode 100644 index 0000000..38f5489 --- /dev/null +++ b/Tests/Functional/WebTestCase.php @@ -0,0 +1,40 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace FOS\ElasticaBundle\Tests\Functional; + +use Symfony\Bundle\FrameworkBundle\Tests\Functional\WebTestCase as BaseWebTestCase; + +class WebTestCase extends BaseWebTestCase +{ + protected static function getKernelClass() + { + require_once __DIR__.'/app/AppKernel.php'; + + return 'FOS\ElasticaBundle\Tests\Functional\app\AppKernel'; + } + + protected static function createKernel(array $options = array()) + { + $class = self::getKernelClass(); + + if (!isset($options['test_case'])) { + throw new \InvalidArgumentException('The option "test_case" must be set.'); + } + + return new $class( + $options['test_case'], + isset($options['root_config']) ? $options['root_config'] : 'config.yml', + isset($options['environment']) ? $options['environment'] : 'foselasticabundle'.strtolower($options['test_case']), + isset($options['debug']) ? $options['debug'] : true + ); + } +} diff --git a/Tests/Functional/app/AppKernel.php b/Tests/Functional/app/AppKernel.php new file mode 100644 index 0000000..d75910a --- /dev/null +++ b/Tests/Functional/app/AppKernel.php @@ -0,0 +1,118 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Tests\Functional\app; + +// get the autoload file +$dir = __DIR__; +$lastDir = null; +while ($dir !== $lastDir) { + $lastDir = $dir; + + if (file_exists($dir.'/autoload.php')) { + require_once $dir.'/autoload.php'; + break; + } + + if (file_exists($dir.'/autoload.php.dist')) { + require_once $dir.'/autoload.php.dist'; + break; + } + + if (file_exists($dir.'/vendor/autoload.php')) { + require_once $dir.'/vendor/autoload.php'; + break; + } + + $dir = dirname($dir); +} + +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\HttpKernel\Kernel; + +/** + * App Test Kernel for functional tests. + * + * @author Johannes M. Schmitt + */ +class AppKernel extends Kernel +{ + private $testCase; + private $rootConfig; + + public function __construct($testCase, $rootConfig, $environment, $debug) + { + if (!is_dir(__DIR__.'/'.$testCase)) { + throw new \InvalidArgumentException(sprintf('The test case "%s" does not exist.', $testCase)); + } + $this->testCase = $testCase; + + $fs = new Filesystem(); + if (!$fs->isAbsolutePath($rootConfig) && !file_exists($rootConfig = __DIR__.'/'.$testCase.'/'.$rootConfig)) { + throw new \InvalidArgumentException(sprintf('The root config "%s" does not exist.', $rootConfig)); + } + $this->rootConfig = $rootConfig; + + parent::__construct($environment, $debug); + } + + public function registerBundles() + { + if (!file_exists($filename = $this->getRootDir().'/'.$this->testCase.'/bundles.php')) { + throw new \RuntimeException(sprintf('The bundles file "%s" does not exist.', $filename)); + } + + return include $filename; + } + + public function init() + { + } + + public function getRootDir() + { + return __DIR__; + } + + public function getCacheDir() + { + return sys_get_temp_dir().'/'.Kernel::VERSION.'/'.$this->testCase.'/cache/'.$this->environment; + } + + public function getLogDir() + { + return sys_get_temp_dir().'/'.Kernel::VERSION.'/'.$this->testCase.'/logs'; + } + + public function registerContainerConfiguration(LoaderInterface $loader) + { + $loader->load($this->rootConfig); + } + + public function serialize() + { + return serialize(array($this->testCase, $this->rootConfig, $this->getEnvironment(), $this->isDebug())); + } + + public function unserialize($str) + { + call_user_func_array(array($this, '__construct'), unserialize($str)); + } + + protected function getKernelParameters() + { + $parameters = parent::getKernelParameters(); + $parameters['kernel.test_case'] = $this->testCase; + + return $parameters; + } +} diff --git a/Tests/Functional/app/Basic/bundles.php b/Tests/Functional/app/Basic/bundles.php new file mode 100644 index 0000000..7bcaae8 --- /dev/null +++ b/Tests/Functional/app/Basic/bundles.php @@ -0,0 +1,13 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Tests\Functional\app\ORM; + +class IndexableService +{ + public function isIndexable() + { + return true; + } + + public static function isntIndexable() + { + return false; + } +} diff --git a/Tests/Functional/app/ORM/bundles.php b/Tests/Functional/app/ORM/bundles.php new file mode 100644 index 0000000..25db3fe --- /dev/null +++ b/Tests/Functional/app/ORM/bundles.php @@ -0,0 +1,13 @@ +assertSame($result, $hybridResult->getResult()); + $this->assertNull($hybridResult->getTransformed()); + } +} diff --git a/Tests/Index/AliasProcessorTest.php b/Tests/Index/AliasProcessorTest.php new file mode 100644 index 0000000..f1592b2 --- /dev/null +++ b/Tests/Index/AliasProcessorTest.php @@ -0,0 +1,223 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Tests\Index; + +use Elastica\Exception\ResponseException; +use Elastica\Request; +use Elastica\Response; +use FOS\ElasticaBundle\Configuration\IndexConfig; +use FOS\ElasticaBundle\Index\AliasProcessor; + +class AliasProcessorTest extends \PHPUnit_Framework_TestCase +{ + /** + * @var AliasProcessor + */ + private $processor; + + /** + * @dataProvider getSetRootNameData + * @param string $name + * @param array $configArray + * @param string $resultStartsWith + */ + public function testSetRootName($name, $configArray, $resultStartsWith) + { + $indexConfig = new IndexConfig($name, array(), $configArray); + $index = $this->getMockBuilder('FOS\\ElasticaBundle\\Elastica\\Index') + ->disableOriginalConstructor() + ->getMock(); + $index->expects($this->once()) + ->method('overrideName') + ->with($this->stringStartsWith($resultStartsWith)); + + $this->processor->setRootName($indexConfig, $index); + } + + public function testSwitchAliasNoAliasSet() + { + $indexConfig = new IndexConfig('name', array(), array()); + list($index, $client) = $this->getMockedIndex('unique_name'); + + $client->expects($this->at(0)) + ->method('request') + ->with('_aliases', 'GET') + ->willReturn(new Response(array())); + $client->expects($this->at(1)) + ->method('request') + ->with('_aliases', 'POST', array('actions' => array( + array('add' => array('index' => 'unique_name', 'alias' => 'name')) + ))); + + $this->processor->switchIndexAlias($indexConfig, $index, false); + } + + public function testSwitchAliasExistingAliasSet() + { + $indexConfig = new IndexConfig('name', array(), array()); + list($index, $client) = $this->getMockedIndex('unique_name'); + + $client->expects($this->at(0)) + ->method('request') + ->with('_aliases', 'GET') + ->willReturn(new Response(array( + 'old_unique_name' => array('aliases' => array('name')) + ))); + $client->expects($this->at(1)) + ->method('request') + ->with('_aliases', 'POST', array('actions' => array( + array('remove' => array('index' => 'old_unique_name', 'alias' => 'name')), + array('add' => array('index' => 'unique_name', 'alias' => 'name')) + ))); + + $this->processor->switchIndexAlias($indexConfig, $index, false); + } + + /** + * @expectedException \RuntimeException + */ + public function testSwitchAliasThrowsWhenMoreThanOneExists() + { + $indexConfig = new IndexConfig('name', array(), array()); + list($index, $client) = $this->getMockedIndex('unique_name'); + + $client->expects($this->at(0)) + ->method('request') + ->with('_aliases', 'GET') + ->willReturn(new Response(array( + 'old_unique_name' => array('aliases' => array('name')), + 'another_old_unique_name' => array('aliases' => array('name')) + ))); + + $this->processor->switchIndexAlias($indexConfig, $index, false); + } + + /** + * @expectedException \FOS\ElasticaBundle\Exception\AliasIsIndexException + */ + public function testSwitchAliasThrowsWhenAliasIsAnIndex() + { + $indexConfig = new IndexConfig('name', array(), array()); + list($index, $client) = $this->getMockedIndex('unique_name'); + + $client->expects($this->at(0)) + ->method('request') + ->with('_aliases', 'GET') + ->willReturn(new Response(array( + 'name' => array(), + ))); + + $this->processor->switchIndexAlias($indexConfig, $index, false); + } + + public function testSwitchAliasDeletesIndexCollisionIfForced() + { + $indexConfig = new IndexConfig('name', array(), array()); + list($index, $client) = $this->getMockedIndex('unique_name'); + + $client->expects($this->at(0)) + ->method('request') + ->with('_aliases', 'GET') + ->willReturn(new Response(array( + 'name' => array(), + ))); + $client->expects($this->at(1)) + ->method('request') + ->with('name', 'DELETE'); + + $this->processor->switchIndexAlias($indexConfig, $index, true); + } + + public function testSwitchAliasDeletesOldIndex() + { + $indexConfig = new IndexConfig('name', array(), array()); + list($index, $client) = $this->getMockedIndex('unique_name'); + + $client->expects($this->at(0)) + ->method('request') + ->with('_aliases', 'GET') + ->willReturn(new Response(array( + 'old_unique_name' => array('aliases' => array('name')), + ))); + $client->expects($this->at(1)) + ->method('request') + ->with('_aliases', 'POST', array('actions' => array( + array('remove' => array('index' => 'old_unique_name', 'alias' => 'name')), + array('add' => array('index' => 'unique_name', 'alias' => 'name')) + ))); + $client->expects($this->at(2)) + ->method('request') + ->with('old_unique_name', 'DELETE'); + + $this->processor->switchIndexAlias($indexConfig, $index, true); + } + + public function testSwitchAliasCleansUpOnRenameFailure() + { + $indexConfig = new IndexConfig('name', array(), array()); + list($index, $client) = $this->getMockedIndex('unique_name'); + + $client->expects($this->at(0)) + ->method('request') + ->with('_aliases', 'GET') + ->willReturn(new Response(array( + 'old_unique_name' => array('aliases' => array('name')), + ))); + $client->expects($this->at(1)) + ->method('request') + ->with('_aliases', 'POST', array('actions' => array( + array('remove' => array('index' => 'old_unique_name', 'alias' => 'name')), + array('add' => array('index' => 'unique_name', 'alias' => 'name')) + ))) + ->will($this->throwException(new ResponseException(new Request(''), new Response('')))); + $client->expects($this->at(2)) + ->method('request') + ->with('unique_name', 'DELETE'); + // Not an annotation: we do not want a RuntimeException until now. + $this->setExpectedException('RuntimeException'); + + $this->processor->switchIndexAlias($indexConfig, $index, true); + } + + public function getSetRootNameData() + { + return array( + array('name', array(), 'name_'), + array('name', array('elasticSearchName' => 'notname'), 'notname_') + ); + } + + protected function setUp() + { + $this->processor = new AliasProcessor(); + } + + private function getMockedIndex($name) + { + $index = $this->getMockBuilder('FOS\\ElasticaBundle\\Elastica\\Index') + ->disableOriginalConstructor() + ->getMock(); + + $client = $this->getMockBuilder('Elastica\\Client') + ->disableOriginalConstructor() + ->getMock(); + $index->expects($this->any()) + ->method('getClient') + ->willReturn($client); + + $index->expects($this->any()) + ->method('getName') + ->willReturn($name); + + return array($index, $client); + } +} diff --git a/Tests/Index/IndexManagerTest.php b/Tests/Index/IndexManagerTest.php new file mode 100644 index 0000000..78a3d28 --- /dev/null +++ b/Tests/Index/IndexManagerTest.php @@ -0,0 +1,58 @@ +getMockBuilder('FOS\\ElasticaBundle\\Elastica\\Index') + ->disableOriginalConstructor() + ->getMock(); + + $index->expects($this->any()) + ->method('getName') + ->will($this->returnValue($indexName)); + + $this->indexes[$indexName] = $index; + } + + $this->indexManager = new IndexManager($this->indexes, $this->indexes['index2']); + } + + public function testGetAllIndexes() + { + $this->assertEquals($this->indexes, $this->indexManager->getAllIndexes()); + } + + public function testGetIndex() + { + $this->assertEquals($this->indexes['index1'], $this->indexManager->getIndex('index1')); + $this->assertEquals($this->indexes['index2'], $this->indexManager->getIndex('index2')); + $this->assertEquals($this->indexes['index3'], $this->indexManager->getIndex('index3')); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testGetIndexShouldThrowExceptionForInvalidName() + { + $this->indexManager->getIndex('index4'); + } + + public function testGetDefaultIndex() + { + $this->assertEquals('index2', $this->indexManager->getIndex()->getName()); + $this->assertEquals('index2', $this->indexManager->getDefaultIndex()->getName()); + } +} diff --git a/Tests/Index/ResetterTest.php b/Tests/Index/ResetterTest.php new file mode 100644 index 0000000..9b4cd05 --- /dev/null +++ b/Tests/Index/ResetterTest.php @@ -0,0 +1,274 @@ +mockIndex($indexName, $indexConfig); + + $this->configManager->expects($this->once()) + ->method('getIndexNames') + ->will($this->returnValue(array($indexName))); + + $this->dispatcherExpects(array( + array(IndexResetEvent::PRE_INDEX_RESET, $this->isInstanceOf('FOS\\ElasticaBundle\\Event\\IndexResetEvent')), + array(IndexResetEvent::POST_INDEX_RESET, $this->isInstanceOf('FOS\\ElasticaBundle\\Event\\IndexResetEvent')) + )); + + $this->elasticaClient->expects($this->exactly(2)) + ->method('request') + ->withConsecutive( + array('index1/', 'DELETE'), + array('index1/', 'PUT', array(), array()) + ); + + $this->resetter->resetAllIndexes(); + } + + public function testResetIndex() + { + $indexConfig = new IndexConfig('index1', array(), array()); + $this->mockIndex('index1', $indexConfig); + + $this->dispatcherExpects(array( + array(IndexResetEvent::PRE_INDEX_RESET, $this->isInstanceOf('FOS\\ElasticaBundle\\Event\\IndexResetEvent')), + array(IndexResetEvent::POST_INDEX_RESET, $this->isInstanceOf('FOS\\ElasticaBundle\\Event\\IndexResetEvent')) + )); + + $this->elasticaClient->expects($this->exactly(2)) + ->method('request') + ->withConsecutive( + array('index1/', 'DELETE'), + array('index1/', 'PUT', array(), array()) + ); + + $this->resetter->resetIndex('index1'); + } + + public function testResetIndexWithDifferentName() + { + $indexConfig = new IndexConfig('index1', array(), array( + 'elasticSearchName' => 'notIndex1' + )); + $this->mockIndex('index1', $indexConfig); + $this->dispatcherExpects(array( + array(IndexResetEvent::PRE_INDEX_RESET, $this->isInstanceOf('FOS\\ElasticaBundle\\Event\\IndexResetEvent')), + array(IndexResetEvent::POST_INDEX_RESET, $this->isInstanceOf('FOS\\ElasticaBundle\\Event\\IndexResetEvent')) + )); + + $this->elasticaClient->expects($this->exactly(2)) + ->method('request') + ->withConsecutive( + array('index1/', 'DELETE'), + array('index1/', 'PUT', array(), array()) + ); + + $this->resetter->resetIndex('index1'); + } + + public function testResetIndexWithDifferentNameAndAlias() + { + $indexConfig = new IndexConfig('index1', array(), array( + 'elasticSearchName' => 'notIndex1', + 'useAlias' => true + )); + $index = $this->mockIndex('index1', $indexConfig); + $this->dispatcherExpects(array( + array(IndexResetEvent::PRE_INDEX_RESET, $this->isInstanceOf('FOS\\ElasticaBundle\\Event\\IndexResetEvent')), + array(IndexResetEvent::POST_INDEX_RESET, $this->isInstanceOf('FOS\\ElasticaBundle\\Event\\IndexResetEvent')) + )); + + $this->aliasProcessor->expects($this->once()) + ->method('switchIndexAlias') + ->with($indexConfig, $index, false); + + $this->elasticaClient->expects($this->exactly(2)) + ->method('request') + ->withConsecutive( + array('index1/', 'DELETE'), + array('index1/', 'PUT', array(), array()) + ); + + $this->resetter->resetIndex('index1'); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testFailureWhenMissingIndexDoesntDispatch() + { + $this->configManager->expects($this->once()) + ->method('getIndexConfiguration') + ->with('nonExistant') + ->will($this->throwException(new \InvalidArgumentException)); + + $this->indexManager->expects($this->never()) + ->method('getIndex'); + + $this->resetter->resetIndex('nonExistant'); + } + + public function testResetType() + { + $typeConfig = new TypeConfig('type', array(), array()); + $this->mockType('type', 'index', $typeConfig); + + $this->dispatcherExpects(array( + array(TypeResetEvent::PRE_TYPE_RESET, $this->isInstanceOf('FOS\\ElasticaBundle\\Event\\TypeResetEvent')), + array(TypeResetEvent::POST_TYPE_RESET, $this->isInstanceOf('FOS\\ElasticaBundle\\Event\\TypeResetEvent')) + )); + + $this->elasticaClient->expects($this->exactly(2)) + ->method('request') + ->withConsecutive( + array('index/type/', 'DELETE'), + array('index/type/_mapping', 'PUT', array('type' => array()), array()) + ); + + $this->resetter->resetIndexType('index', 'type'); + } + + /** + * @expectedException \InvalidArgumentException + */ + public function testNonExistantResetType() + { + $this->configManager->expects($this->once()) + ->method('getTypeConfiguration') + ->with('index', 'nonExistant') + ->will($this->throwException(new \InvalidArgumentException)); + + $this->indexManager->expects($this->never()) + ->method('getIndex'); + + $this->resetter->resetIndexType('index', 'nonExistant'); + } + + public function testPostPopulateWithoutAlias() + { + $this->mockIndex('index', new IndexConfig('index', array(), array())); + + $this->indexManager->expects($this->never()) + ->method('getIndex'); + $this->aliasProcessor->expects($this->never()) + ->method('switchIndexAlias'); + + $this->resetter->postPopulate('index'); + } + + public function testPostPopulate() + { + $indexConfig = new IndexConfig('index', array(), array( 'useAlias' => true)); + $index = $this->mockIndex('index', $indexConfig); + + $this->aliasProcessor->expects($this->once()) + ->method('switchIndexAlias') + ->with($indexConfig, $index); + + $this->resetter->postPopulate('index'); + } + + private function dispatcherExpects(array $events) + { + $expectation = $this->dispatcher->expects($this->exactly(count($events))) + ->method('dispatch'); + + call_user_func_array(array($expectation, 'withConsecutive'), $events); + } + + private function mockIndex($indexName, IndexConfig $config, $mapping = array()) + { + $this->configManager->expects($this->atLeast(1)) + ->method('getIndexConfiguration') + ->with($indexName) + ->will($this->returnValue($config)); + $index = new Index($this->elasticaClient, $indexName); + $this->indexManager->expects($this->any()) + ->method('getIndex') + ->with($indexName) + ->willReturn($index); + $this->mappingBuilder->expects($this->any()) + ->method('buildIndexMapping') + ->with($config) + ->willReturn($mapping); + + return $index; + } + + private function mockType($typeName, $indexName, TypeConfig $config, $mapping = array()) + { + $this->configManager->expects($this->atLeast(1)) + ->method('getTypeConfiguration') + ->with($indexName, $typeName) + ->will($this->returnValue($config)); + $index = new Index($this->elasticaClient, $indexName); + $this->indexManager->expects($this->once()) + ->method('getIndex') + ->with($indexName) + ->willReturn($index); + $this->mappingBuilder->expects($this->once()) + ->method('buildTypeMapping') + ->with($config) + ->willReturn($mapping); + + return $index; + } + + protected function setUp() + { + $this->aliasProcessor = $this->getMockBuilder('FOS\\ElasticaBundle\\Index\\AliasProcessor') + ->disableOriginalConstructor() + ->getMock(); + $this->configManager = $this->getMockBuilder('FOS\\ElasticaBundle\\Configuration\\ConfigManager') + ->disableOriginalConstructor() + ->getMock(); + $this->dispatcher = $this->getMockBuilder('Symfony\\Component\\EventDispatcher\\EventDispatcherInterface') + ->getMock(); + $this->elasticaClient = $this->getMockBuilder('Elastica\\Client') + ->disableOriginalConstructor() + ->getMock(); + $this->indexManager = $this->getMockBuilder('FOS\\ElasticaBundle\\Index\\IndexManager') + ->disableOriginalConstructor() + ->getMock(); + $this->mappingBuilder = $this->getMockBuilder('FOS\\ElasticaBundle\\Index\\MappingBuilder') + ->disableOriginalConstructor() + ->getMock(); + + $this->resetter = new Resetter( + $this->configManager, + $this->indexManager, + $this->aliasProcessor, + $this->mappingBuilder, + $this->dispatcher + ); + } +} diff --git a/Tests/IndexManagerTest.php b/Tests/IndexManagerTest.php deleted file mode 100644 index 9a03986..0000000 --- a/Tests/IndexManagerTest.php +++ /dev/null @@ -1,58 +0,0 @@ -defaultIndexName = 'index2'; - $this->indexesByName = array( - 'index1' => 'test1', - 'index2' => 'test2', - ); - - /** @var $defaultIndex \PHPUnit_Framework_MockObject_MockObject|\Elastica_Index */ - $defaultIndex = $this->getMockBuilder('Elastica_Index') - ->disableOriginalConstructor() - ->getMock(); - - $defaultIndex->expects($this->any()) - ->method('getName') - ->will($this->returnValue($this->defaultIndexName)); - - $this->indexManager = new IndexManager($this->indexesByName, $defaultIndex); - } - - public function testGetAllIndexes() - { - $this->assertEquals($this->indexesByName, $this->indexManager->getAllIndexes()); - } - - public function testGetIndex() - { - $this->assertEquals($this->indexesByName['index1'], $this->indexManager->getIndex('index1')); - $this->assertEquals($this->indexesByName['index2'], $this->indexManager->getIndex('index2')); - } - - /** - * @expectedException \InvalidArgumentException - */ - public function testGetIndexShouldThrowExceptionForInvalidName() - { - $this->indexManager->getIndex('index3'); - } - - public function testGetDefaultIndex() - { - $this->assertEquals('test2', $this->indexManager->getIndex()); - $this->assertEquals('test2', $this->indexManager->getDefaultIndex()); - } -} diff --git a/Tests/Integration/MappingTest.php b/Tests/Integration/MappingTest.php new file mode 100644 index 0000000..ae7e409 --- /dev/null +++ b/Tests/Integration/MappingTest.php @@ -0,0 +1,15 @@ + + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace FOS\ElasticaBundle\Tests\Integration; + +class MappingTest +{ +} diff --git a/Tests/Logger/ElasticaLoggerTest.php b/Tests/Logger/ElasticaLoggerTest.php index 3cf6d2d..7d90639 100644 --- a/Tests/Logger/ElasticaLoggerTest.php +++ b/Tests/Logger/ElasticaLoggerTest.php @@ -9,10 +9,44 @@ use FOS\ElasticaBundle\Logger\ElasticaLogger; */ class ElasticaLoggerTest extends \PHPUnit_Framework_TestCase { + /** + * @return \PHPUnit_Framework_MockObject_MockObject|\Symfony\Component\HttpKernel\Log\LoggerInterface + */ + private function getMockLogger() + { + return $this->getMockBuilder('Symfony\Component\HttpKernel\Log\LoggerInterface') + ->disableOriginalConstructor() + ->getMock(); + } + + /** + * @param string $level + * @param string $message + * @param array $context + * + * @return ElasticaLogger + */ + private function getMockLoggerForLevelMessageAndContext($level, $message, $context) + { + $loggerMock = $this->getMockBuilder('Symfony\Component\HttpKernel\Log\LoggerInterface') + ->disableOriginalConstructor() + ->getMock(); + + $loggerMock->expects($this->once()) + ->method($level) + ->with( + $this->equalTo($message), + $this->equalTo($context) + ); + + $elasticaLogger = new ElasticaLogger($loggerMock); + + return $elasticaLogger; + } public function testGetZeroIfNoQueriesAdded() { - $elasticaLogger = new ElasticaLogger; + $elasticaLogger = new ElasticaLogger(); $this->assertEquals(0, $elasticaLogger->getNbQueries()); } @@ -36,15 +70,19 @@ class ElasticaLoggerTest extends \PHPUnit_Framework_TestCase $method = 'testMethod'; $data = array('data'); $time = 12; + $connection = array('host' => 'localhost', 'port' => '8999', 'transport' => 'https'); + $query = array('search_type' => 'dfs_query_then_fetch'); $expected = array( 'path' => $path, 'method' => $method, 'data' => $data, - 'executionMS' => $time + 'executionMS' => $time, + 'connection' => $connection, + 'queryString' => $query, ); - $elasticaLogger->logQuery($path, $method, $data, $time); + $elasticaLogger->logQuery($path, $method, $data, $time, $connection, $query); $returnedQueries = $elasticaLogger->getQueries(); $this->assertEquals($expected, $returnedQueries[0]); } @@ -63,10 +101,7 @@ class ElasticaLoggerTest extends \PHPUnit_Framework_TestCase public function testQueryIsLogged() { - /** @var $loggerMock \PHPUnit_Framework_MockObject_MockObject|\Symfony\Component\HttpKernel\Log\LoggerInterface */ - $loggerMock = $this->getMockBuilder('Symfony\Component\HttpKernel\Log\LoggerInterface') - ->disableOriginalConstructor() - ->getMock(); + $loggerMock = $this->getMockLogger(); $elasticaLogger = new ElasticaLogger($loggerMock); @@ -87,4 +122,54 @@ class ElasticaLoggerTest extends \PHPUnit_Framework_TestCase $elasticaLogger->logQuery($path, $method, $data, $time); } + /** + * @return array + */ + public function logLevels() + { + return array( + array('emergency'), + array('alert'), + array('critical'), + array('error'), + array('warning'), + array('notice'), + array('info'), + array('debug'), + ); + } + + /** + * @dataProvider logLevels + */ + public function testMessagesCanBeLoggedAtSpecificLogLevels($level) + { + $message = 'foo'; + $context = array('data'); + + $loggerMock = $this->getMockLoggerForLevelMessageAndContext($level, $message, $context); + + call_user_func(array($loggerMock, $level), $message, $context); + } + + public function testMessagesCanBeLoggedToArbitraryLevels() + { + $loggerMock = $this->getMockLogger(); + + $level = 'info'; + $message = 'foo'; + $context = array('data'); + + $loggerMock->expects($this->once()) + ->method('log') + ->with( + $level, + $message, + $context + ); + + $elasticaLogger = new ElasticaLogger($loggerMock); + + $elasticaLogger->log($level, $message, $context); + } } diff --git a/Tests/Manager/RepositoryManagerTest.php b/Tests/Manager/RepositoryManagerTest.php index 8cdf1b4..71bb076 100644 --- a/Tests/Manager/RepositoryManagerTest.php +++ b/Tests/Manager/RepositoryManagerTest.php @@ -4,16 +4,19 @@ namespace FOS\ElasticaBundle\Tests\Manager; use FOS\ElasticaBundle\Manager\RepositoryManager; -class CustomRepository{} +class CustomRepository +{ +} -class Entity{} +class Entity +{ +} /** * @author Richard Miller */ class RepositoryManagerTest extends \PHPUnit_Framework_TestCase { - public function testThatGetRepositoryReturnsDefaultRepository() { /** @var $finderMock \PHPUnit_Framework_MockObject_MockObject|\FOS\ElasticaBundle\Finder\TransformedFinder */ @@ -97,5 +100,4 @@ class RepositoryManagerTest extends \PHPUnit_Framework_TestCase $manager->addEntity($entityName, $finderMock, 'FOS\ElasticaBundle\Tests\MissingRepository'); $manager->getRepository('Missing Entity'); } - } diff --git a/Tests/Persister/ObjectPersisterTest.php b/Tests/Persister/ObjectPersisterTest.php index 0a46553..06039f0 100644 --- a/Tests/Persister/ObjectPersisterTest.php +++ b/Tests/Persister/ObjectPersisterTest.php @@ -31,26 +31,16 @@ class InvalidObjectPersister extends ObjectPersister class ObjectPersisterTest extends \PHPUnit_Framework_TestCase { - public function setUp() - { - if (!class_exists('Elastica_Type')) { - $this->markTestSkipped('The Elastica library classes are not available'); - } - } - public function testThatCanReplaceObject() { $transformer = $this->getTransformer(); - /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\Elastica_Type */ - $typeMock = $this->getMockBuilder('Elastica_Type') + /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\Elastica\Type */ + $typeMock = $this->getMockBuilder('Elastica\Type') ->disableOriginalConstructor() ->getMock(); $typeMock->expects($this->once()) - ->method('deleteById') - ->with($this->equalTo(123)); - $typeMock->expects($this->once()) - ->method('addDocument'); + ->method('updateDocuments'); $fields = array('name' => array()); @@ -65,8 +55,8 @@ class ObjectPersisterTest extends \PHPUnit_Framework_TestCase { $transformer = $this->getTransformer(); - /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\Elastica_Type */ - $typeMock = $this->getMockBuilder('Elastica_Type') + /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\Elastica\Type */ + $typeMock = $this->getMockBuilder('Elastica\Type') ->disableOriginalConstructor() ->getMock(); $typeMock->expects($this->never()) @@ -84,14 +74,14 @@ class ObjectPersisterTest extends \PHPUnit_Framework_TestCase { $transformer = $this->getTransformer(); - /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\Elastica_Type */ - $typeMock = $this->getMockBuilder('Elastica_Type') + /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\Elastica\Type */ + $typeMock = $this->getMockBuilder('Elastica\Type') ->disableOriginalConstructor() ->getMock(); $typeMock->expects($this->never()) ->method('deleteById'); $typeMock->expects($this->once()) - ->method('addDocument'); + ->method('addDocuments'); $fields = array('name' => array()); @@ -106,8 +96,8 @@ class ObjectPersisterTest extends \PHPUnit_Framework_TestCase { $transformer = $this->getTransformer(); - /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\Elastica_Type */ - $typeMock = $this->getMockBuilder('Elastica_Type') + /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\Elastica\Type */ + $typeMock = $this->getMockBuilder('Elastica\Type') ->disableOriginalConstructor() ->getMock(); $typeMock->expects($this->never()) @@ -125,12 +115,12 @@ class ObjectPersisterTest extends \PHPUnit_Framework_TestCase { $transformer = $this->getTransformer(); - /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\Elastica_Type */ - $typeMock = $this->getMockBuilder('Elastica_Type') + /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\Elastica\Type */ + $typeMock = $this->getMockBuilder('Elastica\Type') ->disableOriginalConstructor() ->getMock(); $typeMock->expects($this->once()) - ->method('deleteById'); + ->method('deleteDocuments'); $typeMock->expects($this->never()) ->method('addDocument'); @@ -147,8 +137,8 @@ class ObjectPersisterTest extends \PHPUnit_Framework_TestCase { $transformer = $this->getTransformer(); - /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\Elastica_Type */ - $typeMock = $this->getMockBuilder('Elastica_Type') + /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\Elastica\Type */ + $typeMock = $this->getMockBuilder('Elastica\Type') ->disableOriginalConstructor() ->getMock(); $typeMock->expects($this->never()) @@ -166,8 +156,8 @@ class ObjectPersisterTest extends \PHPUnit_Framework_TestCase { $transformer = $this->getTransformer(); - /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\Elastica_Type */ - $typeMock = $this->getMockBuilder('Elastica_Type') + /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\Elastica\Type */ + $typeMock = $this->getMockBuilder('Elastica\Type') ->disableOriginalConstructor() ->getMock(); $typeMock->expects($this->never()) @@ -190,8 +180,8 @@ class ObjectPersisterTest extends \PHPUnit_Framework_TestCase { $transformer = $this->getTransformer(); - /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\Elastica_Type */ - $typeMock = $this->getMockBuilder('Elastica_Type') + /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\Elastica\Type */ + $typeMock = $this->getMockBuilder('Elastica\Type') ->disableOriginalConstructor() ->getMock(); $typeMock->expects($this->never()) @@ -213,10 +203,7 @@ class ObjectPersisterTest extends \PHPUnit_Framework_TestCase private function getTransformer() { $transformer = new ModelToElasticaAutoTransformer(); - - if (class_exists('Symfony\Component\PropertyAccess\PropertyAccess')) { - $transformer->setPropertyAccessor(PropertyAccess::getPropertyAccessor()); - } + $transformer->setPropertyAccessor(PropertyAccess::createPropertyAccessor()); return $transformer; } diff --git a/Tests/Persister/ObjectSerializerPersisterTest.php b/Tests/Persister/ObjectSerializerPersisterTest.php new file mode 100644 index 0000000..0536d06 --- /dev/null +++ b/Tests/Persister/ObjectSerializerPersisterTest.php @@ -0,0 +1,119 @@ +id; + } + + public function getName() + { + return $this->name; + } +} + +class ObjectSerializerPersisterTest extends \PHPUnit_Framework_TestCase +{ + public function testThatCanReplaceObject() + { + $transformer = $this->getTransformer(); + + /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\Elastica\Type */ + $typeMock = $this->getMockBuilder('Elastica\Type') + ->disableOriginalConstructor() + ->getMock(); + $typeMock->expects($this->once()) + ->method('updateDocuments'); + + $serializerMock = $this->getMockBuilder('FOS\ElasticaBundle\Serializer\Callback')->getMock(); + $serializerMock->expects($this->once())->method('serialize'); + + $objectPersister = new ObjectSerializerPersister($typeMock, $transformer, 'SomeClass', array($serializerMock, 'serialize')); + $objectPersister->replaceOne(new POPO()); + } + + public function testThatCanInsertObject() + { + $transformer = $this->getTransformer(); + + /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\Elastica\Type */ + $typeMock = $this->getMockBuilder('Elastica\Type') + ->disableOriginalConstructor() + ->getMock(); + $typeMock->expects($this->never()) + ->method('deleteById'); + $typeMock->expects($this->once()) + ->method('addDocuments'); + + $serializerMock = $this->getMockBuilder('FOS\ElasticaBundle\Serializer\Callback')->getMock(); + $serializerMock->expects($this->once())->method('serialize'); + + $objectPersister = new ObjectSerializerPersister($typeMock, $transformer, 'SomeClass', array($serializerMock, 'serialize')); + $objectPersister->insertOne(new POPO()); + } + + public function testThatCanDeleteObject() + { + $transformer = $this->getTransformer(); + + /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\Elastica\Type */ + $typeMock = $this->getMockBuilder('Elastica\Type') + ->disableOriginalConstructor() + ->getMock(); + $typeMock->expects($this->once()) + ->method('deleteDocuments'); + $typeMock->expects($this->never()) + ->method('addDocument'); + + $serializerMock = $this->getMockBuilder('FOS\ElasticaBundle\Serializer\Callback')->getMock(); + $serializerMock->expects($this->once())->method('serialize'); + + $objectPersister = new ObjectSerializerPersister($typeMock, $transformer, 'SomeClass', array($serializerMock, 'serialize')); + $objectPersister->deleteOne(new POPO()); + } + + public function testThatCanInsertManyObjects() + { + $transformer = $this->getTransformer(); + + /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\Elastica\Type */ + $typeMock = $this->getMockBuilder('Elastica\Type') + ->disableOriginalConstructor() + ->getMock(); + $typeMock->expects($this->never()) + ->method('deleteById'); + $typeMock->expects($this->never()) + ->method('addObject'); + $typeMock->expects($this->never()) + ->method('addObjects'); + $typeMock->expects($this->once()) + ->method('addDocuments'); + + $serializerMock = $this->getMockBuilder('FOS\ElasticaBundle\Serializer\Callback')->getMock(); + $serializerMock->expects($this->exactly(2))->method('serialize'); + + $objectPersister = new ObjectSerializerPersister($typeMock, $transformer, 'SomeClass', array($serializerMock, 'serialize')); + $objectPersister->insertMany(array(new POPO(), new POPO())); + } + + /** + * @return ModelToElasticaIdentifierTransformer + */ + private function getTransformer() + { + $transformer = new ModelToElasticaIdentifierTransformer(); + $transformer->setPropertyAccessor(PropertyAccess::createPropertyAccessor()); + + return $transformer; + } +} diff --git a/Tests/Provider/IndexableTest.php b/Tests/Provider/IndexableTest.php new file mode 100644 index 0000000..e122ec1 --- /dev/null +++ b/Tests/Provider/IndexableTest.php @@ -0,0 +1,123 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Tests\Provider; + +use FOS\ElasticaBundle\Provider\Indexable; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; + +class IndexableTest extends \PHPUnit_Framework_TestCase +{ + public $container; + + public function testIndexableUnknown() + { + $indexable = new Indexable(array(), $this->container); + $index = $indexable->isObjectIndexable('index', 'type', new Entity()); + + $this->assertTrue($index); + } + + /** + * @dataProvider provideIsIndexableCallbacks + */ + public function testValidIndexableCallbacks($callback, $return) + { + $indexable = new Indexable(array( + 'index/type' => $callback, + ), $this->container); + $index = $indexable->isObjectIndexable('index', 'type', new Entity()); + + $this->assertEquals($return, $index); + } + + /** + * @dataProvider provideInvalidIsIndexableCallbacks + * @expectedException \InvalidArgumentException + */ + public function testInvalidIsIndexableCallbacks($callback) + { + $indexable = new Indexable(array( + 'index/type' => $callback, + ), $this->container); + $indexable->isObjectIndexable('index', 'type', new Entity()); + } + + public function provideInvalidIsIndexableCallbacks() + { + return array( + array('nonexistentEntityMethod'), + array(array('@indexableService', 'internalMethod')), + array(array(new IndexableDecider(), 'internalMethod')), + array(42), + array('entity.getIsIndexable() && nonexistentEntityFunction()'), + ); + } + + public function provideIsIndexableCallbacks() + { + return array( + array('isIndexable', false), + array(array(new IndexableDecider(), 'isIndexable'), true), + array(array('@indexableService', 'isIndexable'), true), + array(array('@indexableService'), true), + array(function (Entity $entity) { return $entity->maybeIndex(); }, true), + array('entity.maybeIndex()', true), + array('!object.isIndexable() && entity.property == "abc"', true), + array('entity.property != "abc"', false), + array('["array", "values"]', true), + array('[]', false) + ); + } + + protected function setUp() + { + $this->container = $this->getMockBuilder('Symfony\\Component\\DependencyInjection\\ContainerInterface') + ->getMock(); + + $this->container->expects($this->any()) + ->method('get') + ->with('indexableService') + ->will($this->returnValue(new IndexableDecider())); + } +} + +class Entity +{ + public $property = 'abc'; + + public function isIndexable() + { + return false; + } + + public function maybeIndex() + { + return true; + } +} + +class IndexableDecider +{ + public function isIndexable(Entity $entity) + { + return !$entity->isIndexable(); + } + + protected function internalMethod() + { + } + + public function __invoke($object) + { + return true; + } +} diff --git a/Tests/RepositoryTest.php b/Tests/RepositoryTest.php index ded8009..7702af2 100644 --- a/Tests/RepositoryTest.php +++ b/Tests/RepositoryTest.php @@ -9,19 +9,11 @@ use FOS\ElasticaBundle\Repository; */ class RepositoryTest extends \PHPUnit_Framework_TestCase { - public function testThatFindCallsFindOnFinder() { $testQuery = 'Test Query'; - /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\FOS\ElasticaBundle\Finder\TransformedFinder */ - $finderMock = $this->getMockBuilder('FOS\ElasticaBundle\Finder\TransformedFinder') - ->disableOriginalConstructor() - ->getMock(); - $finderMock->expects($this->once()) - ->method('find') - ->with($this->equalTo($testQuery)); - + $finderMock = $this->getFinderMock($testQuery); $repository = new Repository($finderMock); $repository->find($testQuery); } @@ -31,14 +23,7 @@ class RepositoryTest extends \PHPUnit_Framework_TestCase $testQuery = 'Test Query'; $testLimit = 20; - /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\FOS\ElasticaBundle\Finder\TransformedFinder */ - $finderMock = $this->getMockBuilder('FOS\ElasticaBundle\Finder\TransformedFinder') - ->disableOriginalConstructor() - ->getMock(); - $finderMock->expects($this->once()) - ->method('find') - ->with($this->equalTo($testQuery), $this->equalTo($testLimit)); - + $finderMock = $this->getFinderMock($testQuery, $testLimit); $repository = new Repository($finderMock); $repository->find($testQuery, $testLimit); } @@ -47,33 +32,45 @@ class RepositoryTest extends \PHPUnit_Framework_TestCase { $testQuery = 'Test Query'; - /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\FOS\ElasticaBundle\Finder\TransformedFinder */ - $finderMock = $this->getMockBuilder('FOS\ElasticaBundle\Finder\TransformedFinder') - ->disableOriginalConstructor() - ->getMock(); - $finderMock->expects($this->once()) - ->method('findPaginated') - ->with($this->equalTo($testQuery)); - + $finderMock = $this->getFinderMock($testQuery, array(), 'findPaginated'); $repository = new Repository($finderMock); $repository->findPaginated($testQuery); } + public function testThatCreatePaginatorCreatesAPaginatorViaFinder() + { + $testQuery = 'Test Query'; + + $finderMock = $this->getFinderMock($testQuery, array(), 'createPaginatorAdapter'); + $repository = new Repository($finderMock); + $repository->createPaginatorAdapter($testQuery); + } + public function testThatFindHybridCallsFindHybridOnFinder() { $testQuery = 'Test Query'; - $testLimit = 20; - /** @var $typeMock \PHPUnit_Framework_MockObject_MockObject|\FOS\ElasticaBundle\Finder\TransformedFinder */ + $finderMock = $this->getFinderMock($testQuery, null, 'findHybrid'); + $repository = new Repository($finderMock); + $repository->findHybrid($testQuery); + } + + /** + * @param string $testQuery + * @param mixed $testLimit + * @param string $method + * + * @return \FOS\ElasticaBundle\Finder\TransformedFinder + */ + private function getFinderMock($testQuery, $testLimit = null, $method = 'find') + { $finderMock = $this->getMockBuilder('FOS\ElasticaBundle\Finder\TransformedFinder') ->disableOriginalConstructor() ->getMock(); $finderMock->expects($this->once()) - ->method('findHybrid') + ->method($method) ->with($this->equalTo($testQuery), $this->equalTo($testLimit)); - $repository = new Repository($finderMock); - $repository->findHybrid($testQuery, $testLimit); + return $finderMock; } - } diff --git a/Tests/ResetterTest.php b/Tests/ResetterTest.php deleted file mode 100644 index 61a18a9..0000000 --- a/Tests/ResetterTest.php +++ /dev/null @@ -1,162 +0,0 @@ -indexConfigsByName = array( - 'foo' => array( - 'index' => $this->getMockElasticaIndex(), - 'config' => array( - 'mappings' => array( - 'a' => array('properties' => array()), - 'b' => array('properties' => array()), - ), - ), - ), - 'bar' => array( - 'index' => $this->getMockElasticaIndex(), - 'config' => array( - 'mappings' => array( - 'a' => array('properties' => array()), - 'b' => array('properties' => array()), - ), - ), - ), - 'parent' => array( - 'index' => $this->getMockElasticaIndex(), - 'config' => array( - 'mappings' => array( - 'a' => array('properties' => array( - 'field_1' => array('_parent' => array('type' => 'b', 'identifier' => 'id')), - 'field_2' => array())), - 'b' => array('properties' => array()), - ), - ), - ), - ); - } - - public function testResetAllIndexes() - { - $this->indexConfigsByName['foo']['index']->expects($this->once()) - ->method('create') - ->with($this->indexConfigsByName['foo']['config'], true); - - $this->indexConfigsByName['bar']['index']->expects($this->once()) - ->method('create') - ->with($this->indexConfigsByName['bar']['config'], true); - - $resetter = new Resetter($this->indexConfigsByName); - $resetter->resetAllIndexes(); - } - - public function testResetIndex() - { - $this->indexConfigsByName['foo']['index']->expects($this->once()) - ->method('create') - ->with($this->indexConfigsByName['foo']['config'], true); - - $this->indexConfigsByName['bar']['index']->expects($this->never()) - ->method('create'); - - $resetter = new Resetter($this->indexConfigsByName); - $resetter->resetIndex('foo'); - } - - /** - * @expectedException \InvalidArgumentException - */ - public function testResetIndexShouldThrowExceptionForInvalidIndex() - { - $resetter = new Resetter($this->indexConfigsByName); - $resetter->resetIndex('baz'); - } - - public function testResetIndexType() - { - $type = $this->getMockElasticaType(); - - $this->indexConfigsByName['foo']['index']->expects($this->once()) - ->method('getType') - ->with('a') - ->will($this->returnValue($type)); - - $type->expects($this->once()) - ->method('delete'); - - $mapping = \Elastica_Type_Mapping::create($this->indexConfigsByName['foo']['config']['mappings']['a']['properties']); - $type->expects($this->once()) - ->method('setMapping') - ->with($mapping); - - $resetter = new Resetter($this->indexConfigsByName); - $resetter->resetIndexType('foo', 'a'); - } - - /** - * @expectedException \InvalidArgumentException - */ - public function testResetIndexTypeShouldThrowExceptionForInvalidIndex() - { - $resetter = new Resetter($this->indexConfigsByName); - $resetter->resetIndexType('baz', 'a'); - } - - /** - * @expectedException \InvalidArgumentException - */ - public function testResetIndexTypeShouldThrowExceptionForInvalidType() - { - $resetter = new Resetter($this->indexConfigsByName); - $resetter->resetIndexType('foo', 'c'); - } - - public function testIndexMappingForParent() - { - $type = $this->getMockElasticaType(); - - $this->indexConfigsByName['parent']['index']->expects($this->once()) - ->method('getType') - ->with('a') - ->will($this->returnValue($type)); - - $type->expects($this->once()) - ->method('delete'); - - $mapping = \Elastica_Type_Mapping::create($this->indexConfigsByName['parent']['config']['mappings']['a']['properties']); - $mapping->setParam('_parent', array('type' => 'b')); - $type->expects($this->once()) - ->method('setMapping') - ->with($mapping); - - $resetter = new Resetter($this->indexConfigsByName); - $resetter->resetIndexType('parent', 'a'); - } - - /** - * @return \Elastica_Index - */ - private function getMockElasticaIndex() - { - return $this->getMockBuilder('Elastica_Index') - ->disableOriginalConstructor() - ->getMock(); - } - - /** - * @return \Elastica_Type - */ - private function getMockElasticaType() - { - return $this->getMockBuilder('Elastica_Type') - ->disableOriginalConstructor() - ->getMock(); - } -} diff --git a/Tests/Transformer/ElasticaToModelTransformerCollectionTest.php b/Tests/Transformer/ElasticaToModelTransformerCollectionTest.php index 2660f07..56a7200 100644 --- a/Tests/Transformer/ElasticaToModelTransformerCollectionTest.php +++ b/Tests/Transformer/ElasticaToModelTransformerCollectionTest.php @@ -2,6 +2,8 @@ namespace FOS\ElasticaBundle\Tests\Transformer; +use Elastica\Document; +use Elastica\Result; use FOS\ElasticaBundle\Transformer\ElasticaToModelTransformerCollection; class ElasticaToModelTransformerCollectionTest extends \PHPUnit_Framework_TestCase @@ -35,7 +37,7 @@ class ElasticaToModelTransformerCollectionTest extends \PHPUnit_Framework_TestCa $this->collection = new ElasticaToModelTransformerCollection($this->transformers = array( 'type1' => $transformer1, 'type2' => $transformer2, - ), array()); + )); } public function testGetObjectClass() @@ -45,7 +47,7 @@ class ElasticaToModelTransformerCollectionTest extends \PHPUnit_Framework_TestCa $objectClasses = $this->collection->getObjectClass(); $this->assertEquals(array( 'type1' => 'FOS\ElasticaBundle\Tests\Transformer\POPO', - 'type2' => 'FOS\ElasticaBundle\Tests\Transformer\POPO2' + 'type2' => 'FOS\ElasticaBundle\Tests\Transformer\POPO2', ), $objectClasses); } @@ -53,8 +55,8 @@ class ElasticaToModelTransformerCollectionTest extends \PHPUnit_Framework_TestCa { $this->collectionSetup(); - $document1 = new \Elastica_Document(123, array('data' => 'lots of data'), 'type1'); - $document2 = new \Elastica_Document(124, array('data' => 'not so much data'), 'type2'); + $document1 = new Document(123, array('data' => 'lots of data'), 'type1'); + $document2 = new Document(124, array('data' => 'not so much data'), 'type2'); $result1 = new POPO(123, 'lots of data'); $result2 = new POPO2(124, 'not so much data'); @@ -80,15 +82,15 @@ class ElasticaToModelTransformerCollectionTest extends \PHPUnit_Framework_TestCa { $this->collectionSetup(); - $document1 = new \Elastica_Document(123, array('data' => 'lots of data'), 'type1'); - $document2 = new \Elastica_Document(124, array('data' => 'not so much data'), 'type1'); + $document1 = new Document(123, array('data' => 'lots of data'), 'type1'); + $document2 = new Document(124, array('data' => 'not so much data'), 'type1'); $result1 = new POPO(123, 'lots of data'); $result2 = new POPO2(124, 'not so much data'); $this->transformers['type1']->expects($this->once()) ->method('transform') - ->with(array($document1,$document2)) - ->will($this->returnValue(array($result1,$result2))); + ->with(array($document1, $document2)) + ->will($this->returnValue(array($result1, $result2))); $results = $this->collection->transform(array($document1, $document2)); @@ -97,6 +99,57 @@ class ElasticaToModelTransformerCollectionTest extends \PHPUnit_Framework_TestCa $result2, ), $results); } + + public function testGetIdentifierFieldReturnsAMapOfIdentifiers() + { + $collection = new ElasticaToModelTransformerCollection(array()); + $identifiers = $collection->getIdentifierField(); + $this->assertInternalType('array', $identifiers); + $this->assertEmpty($identifiers); + + $this->collectionSetup(); + $identifiers = $this->collection->getIdentifierField(); + $this->assertInternalType('array', $identifiers); + $this->assertEquals(array('type1' => 'id', 'type2' => 'id'), $identifiers); + } + + public function elasticaResults() + { + $result = new Result(array('_id' => 123, '_type' => 'type1')); + $transformedObject = new POPO(123, array()); + + return array( + array( + $result, $transformedObject, + ), + ); + } + + /** + * @dataProvider elasticaResults + */ + public function testHybridTransformDecoratesResultsWithHybridResultObjects($result, $transformedObject) + { + $transformer = $this->getMock('FOS\ElasticaBundle\Transformer\ElasticaToModelTransformerInterface'); + $transformer->expects($this->any())->method('getIdentifierField')->will($this->returnValue('id')); + + $transformer + ->expects($this->any()) + ->method('transform') + ->will($this->returnValue(array($transformedObject))); + + $collection = new ElasticaToModelTransformerCollection(array('type1' => $transformer)); + + $hybridResults = $collection->hybridTransform(array($result)); + + $this->assertInternalType('array', $hybridResults); + $this->assertNotEmpty($hybridResults); + $this->assertContainsOnlyInstancesOf('FOS\ElasticaBundle\HybridResult', $hybridResults); + + $hybridResult = array_pop($hybridResults); + $this->assertEquals($result, $hybridResult->getResult()); + $this->assertEquals($transformedObject, $hybridResult->getTransformed()); + } } class POPO @@ -104,6 +157,9 @@ class POPO public $id; public $data; + /** + * @param integer $id + */ public function __construct($id, $data) { $this->data = $data; @@ -118,5 +174,4 @@ class POPO class POPO2 extends POPO { - } diff --git a/Tests/Transformer/ModelToElasticaAutoTransformerTest.php b/Tests/Transformer/ModelToElasticaAutoTransformerTest.php index 798ea38..f45134e 100644 --- a/Tests/Transformer/ModelToElasticaAutoTransformerTest.php +++ b/Tests/Transformer/ModelToElasticaAutoTransformerTest.php @@ -2,6 +2,7 @@ namespace FOS\ElasticaBundle\Tests\Transformer\ModelToElasticaAutoTransformer; +use FOS\ElasticaBundle\Event\TransformEvent; use FOS\ElasticaBundle\Transformer\ModelToElasticaAutoTransformer; use Symfony\Component\PropertyAccess\PropertyAccess; @@ -21,8 +22,8 @@ class POPO public function __construct() { $this->date = new \DateTime('1979-05-05'); - $this->file = new \SplFileInfo(__DIR__ . '/../fixtures/attachment.odt'); - $this->fileContents = file_get_contents(__DIR__ . '/../fixtures/attachment.odt'); + $this->file = new \SplFileInfo(__DIR__.'/../fixtures/attachment.odt'); + $this->fileContents = file_get_contents(__DIR__.'/../fixtures/attachment.odt'); } public function getId() @@ -47,7 +48,7 @@ class POPO { return array( 'key1' => 'value1', - 'key2' => 'value2' + 'key2' => 'value2', ); } @@ -102,20 +103,56 @@ class POPO ); } + public function getObj() + { + return array('foo' => 'foo', 'bar' => 'foo', 'id' => 1); + } + + public function getNestedObject() + { + return array('key1' => (object) array('id' => 1, 'key1sub1' => 'value1sub1', 'key1sub2' => 'value1sub2')); + } + public function getUpper() { return (object) array('id' => 'parent', 'name' => 'a random name'); } + + public function getUpperAlias() + { + return $this->getUpper(); + } } class ModelToElasticaAutoTransformerTest extends \PHPUnit_Framework_TestCase { - public function setUp() + public function testTransformerDispatches() { - if (!class_exists('Elastica_Document')) { - ; - $this->markTestSkipped('The Elastica library classes are not available'); - } + $dispatcher = $this->getMockBuilder('Symfony\Component\EventDispatcher\EventDispatcherInterface') + ->getMock(); + $dispatcher->expects($this->once()) + ->method('dispatch') + ->with( + TransformEvent::POST_TRANSFORM, + $this->isInstanceOf('FOS\ElasticaBundle\Event\TransformEvent') + ); + + $transformer = $this->getTransformer($dispatcher); + $transformer->transform(new POPO(), array()); + } + + public function testPropertyPath() + { + $transformer = $this->getTransformer(); + + $document = $transformer->transform(new POPO(), array('name' => array('property_path' => false))); + $this->assertInstanceOf('Elastica\Document', $document); + $this->assertFalse($document->has('name')); + + $document = $transformer->transform(new POPO(), array('realName' => array('property_path' => 'name'))); + $this->assertInstanceOf('Elastica\Document', $document); + $this->assertTrue($document->has('realName')); + $this->assertEquals('someName', $document->get('realName')); } public function testThatCanTransformObject() @@ -124,7 +161,7 @@ class ModelToElasticaAutoTransformerTest extends \PHPUnit_Framework_TestCase $document = $transformer->transform(new POPO(), array('name' => array())); $data = $document->getData(); - $this->assertInstanceOf('Elastica_Document', $document); + $this->assertInstanceOf('Elastica\Document', $document); $this->assertEquals(123, $document->getId()); $this->assertEquals('someName', $data['name']); } @@ -138,12 +175,12 @@ class ModelToElasticaAutoTransformerTest extends \PHPUnit_Framework_TestCase 'float' => array(), 'bool' => array(), 'date' => array(), - 'falseBool' => array() + 'falseBool' => array(), ) ); $data = $document->getData(); - $this->assertInstanceOf('Elastica_Document', $document); + $this->assertInstanceOf('Elastica\Document', $document); $this->assertEquals(123, $document->getId()); $this->assertEquals('someName', $data['name']); $this->assertEquals(7.2, $data['float']); @@ -171,7 +208,7 @@ class ModelToElasticaAutoTransformerTest extends \PHPUnit_Framework_TestCase $this->assertEquals( array( 'key1' => 'value1', - 'key2' => 'value2' + 'key2' => 'value2', ), $data['array'] ); } @@ -201,15 +238,11 @@ class ModelToElasticaAutoTransformerTest extends \PHPUnit_Framework_TestCase $this->assertTrue(array_key_exists('nullValue', $data)); } + /** + * @expectedException Symfony\Component\PropertyAccess\Exception\RuntimeException + */ public function testThatCannotTransformObjectWhenGetterDoesNotExistForPrivateMethod() { - // Support both Symfony 2.1 (Form component) and 2.2 (PropertyAccess component) - $expectedException = class_exists('Symfony\Component\PropertyAccess\PropertyAccess') - ? 'Symfony\Component\PropertyAccess\Exception\PropertyAccessDeniedException' - : 'Symfony\Component\Form\Exception\PropertyAccessDeniedException'; - - $this->setExpectedException($expectedException); - $transformer = $this->getTransformer(); $transformer->transform(new POPO(), array('desc' => array())); } @@ -220,7 +253,7 @@ class ModelToElasticaAutoTransformerTest extends \PHPUnit_Framework_TestCase $document = $transformer->transform(new POPO(), array('file' => array('type' => 'attachment'))); $data = $document->getData(); - $this->assertEquals(base64_encode(file_get_contents(__DIR__ . '/../fixtures/attachment.odt')), $data['file']); + $this->assertEquals(base64_encode(file_get_contents(__DIR__.'/../fixtures/attachment.odt')), $data['file']); } public function testFileContentsAddedForAttachmentMapping() @@ -230,7 +263,7 @@ class ModelToElasticaAutoTransformerTest extends \PHPUnit_Framework_TestCase $data = $document->getData(); $this->assertEquals( - base64_encode(file_get_contents(__DIR__ . '/../fixtures/attachment.odt')), $data['fileContents'] + base64_encode(file_get_contents(__DIR__.'/../fixtures/attachment.odt')), $data['fileContents'] ); } @@ -238,18 +271,18 @@ class ModelToElasticaAutoTransformerTest extends \PHPUnit_Framework_TestCase { $transformer = $this->getTransformer(); $document = $transformer->transform(new POPO(), array( - 'sub' => array( - 'type' => 'nested', - 'properties' => array('foo' => '~') - ) - )); + 'sub' => array( + 'type' => 'nested', + 'properties' => array('foo' => array()), + ), + )); $data = $document->getData(); $this->assertTrue(array_key_exists('sub', $data)); $this->assertInternalType('array', $data['sub']); $this->assertEquals(array( array('foo' => 'foo'), - array('foo' => 'bar') + array('foo' => 'bar'), ), $data['sub']); } @@ -259,8 +292,8 @@ class ModelToElasticaAutoTransformerTest extends \PHPUnit_Framework_TestCase $document = $transformer->transform(new POPO(), array( 'sub' => array( 'type' => 'object', - 'properties' => array('bar') - ) + 'properties' => array('bar'), + ), )); $data = $document->getData(); @@ -268,18 +301,80 @@ class ModelToElasticaAutoTransformerTest extends \PHPUnit_Framework_TestCase $this->assertInternalType('array', $data['sub']); $this->assertEquals(array( array('bar' => 'foo'), - array('bar' => 'bar') + array('bar' => 'bar'), ), $data['sub']); } + public function testObjectDoesNotRequireProperties() + { + $transformer = $this->getTransformer(); + $document = $transformer->transform(new POPO(), array( + 'obj' => array( + 'type' => 'object', + ), + )); + $data = $document->getData(); + + $this->assertTrue(array_key_exists('obj', $data)); + $this->assertInternalType('array', $data['obj']); + $this->assertEquals(array( + 'foo' => 'foo', + 'bar' => 'foo', + 'id' => 1, + ), $data['obj']); + } + + public function testObjectsMappingOfAtLeastOneAutoMappedObjectAndAtLeastOneManuallyMappedObject() + { + $transformer = $this->getTransformer(); + $document = $transformer->transform( + new POPO(), + array( + 'obj' => array('type' => 'object', 'properties' => array()), + 'nestedObject' => array( + 'type' => 'object', + 'properties' => array( + 'key1sub1' => array( + 'type' => 'string', + 'properties' => array(), + ), + 'key1sub2' => array( + 'type' => 'string', + 'properties' => array(), + ), + ), + ), + ) + ); + $data = $document->getData(); + + $this->assertTrue(array_key_exists('obj', $data)); + $this->assertTrue(array_key_exists('nestedObject', $data)); + $this->assertInternalType('array', $data['obj']); + $this->assertInternalType('array', $data['nestedObject']); + $this->assertEquals( + array( + 'foo' => 'foo', + 'bar' => 'foo', + 'id' => 1, + ), + $data['obj'] + ); + $this->assertEquals( + array( + 'key1sub1' => 'value1sub1', + 'key1sub2' => 'value1sub2', + ), + $data['nestedObject'][0] + ); + } + public function testParentMapping() { $transformer = $this->getTransformer(); $document = $transformer->transform(new POPO(), array( - 'upper' => array( - '_parent' => array('type' => 'upper', 'identifier' => 'id'), - ) - )); + '_parent' => array('type' => 'upper', 'property' => 'upper', 'identifier' => 'id'), + )); $this->assertEquals("parent", $document->getParent()); } @@ -288,24 +383,41 @@ class ModelToElasticaAutoTransformerTest extends \PHPUnit_Framework_TestCase { $transformer = $this->getTransformer(); $document = $transformer->transform(new POPO(), array( - 'upper' => array( - '_parent' => array('type' => 'upper', 'identifier' => 'name'), - ) - )); + '_parent' => array('type' => 'upper', 'property' => 'upper', 'identifier' => 'name'), + )); $this->assertEquals("a random name", $document->getParent()); } + public function testParentMappingWithNullProperty() + { + $transformer = $this->getTransformer(); + $document = $transformer->transform(new POPO(), array( + '_parent' => array('type' => 'upper', 'property' => null, 'identifier' => 'id'), + )); + + $this->assertEquals("parent", $document->getParent()); + } + + public function testParentMappingWithCustomProperty() + { + $transformer = $this->getTransformer(); + $document = $transformer->transform(new POPO(), array( + '_parent' => array('type' => 'upper', 'property' => 'upperAlias', 'identifier' => 'id'), + )); + + $this->assertEquals("parent", $document->getParent()); + } + /** + * @param null|\Symfony\Component\EventDispatcher\EventDispatcherInterface $dispatcher + * * @return ModelToElasticaAutoTransformer */ - private function getTransformer() + private function getTransformer($dispatcher = null) { - $transformer = new ModelToElasticaAutoTransformer(); - - if (class_exists('Symfony\Component\PropertyAccess\PropertyAccess')) { - $transformer->setPropertyAccessor(PropertyAccess::getPropertyAccessor()); - } + $transformer = new ModelToElasticaAutoTransformer(array(), $dispatcher); + $transformer->setPropertyAccessor(PropertyAccess::createPropertyAccessor()); return $transformer; } diff --git a/Tests/Transformer/ModelToElasticaIdentifierTransformerTest.php b/Tests/Transformer/ModelToElasticaIdentifierTransformerTest.php new file mode 100644 index 0000000..aa3d7b7 --- /dev/null +++ b/Tests/Transformer/ModelToElasticaIdentifierTransformerTest.php @@ -0,0 +1,58 @@ +id; + } + + public function getName() + { + return $this->name; + } +} + +class ModelToElasticaIdentifierTransformerTest extends \PHPUnit_Framework_TestCase +{ + public function testGetDocumentWithIdentifierOnly() + { + $transformer = $this->getTransformer(); + $document = $transformer->transform(new POPO(), array()); + $data = $document->getData(); + + $this->assertInstanceOf('Elastica\Document', $document); + $this->assertEquals(123, $document->getId()); + $this->assertCount(0, $data); + } + + public function testGetDocumentWithIdentifierOnlyWithFields() + { + $transformer = $this->getTransformer(); + $document = $transformer->transform(new POPO(), array('name' => array())); + $data = $document->getData(); + + $this->assertInstanceOf('Elastica\Document', $document); + $this->assertEquals(123, $document->getId()); + $this->assertCount(0, $data); + } + + /** + * @return ModelToElasticaIdentifierTransformer + */ + private function getTransformer() + { + $transformer = new ModelToElasticaIdentifierTransformer(); + $transformer->setPropertyAccessor(PropertyAccess::createPropertyAccessor()); + + return $transformer; + } +} diff --git a/Tests/bootstrap.php b/Tests/bootstrap.php deleted file mode 100644 index 30fb165..0000000 --- a/Tests/bootstrap.php +++ /dev/null @@ -1,19 +0,0 @@ - + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace FOS\ElasticaBundle\Transformer; + +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; + +abstract class AbstractElasticaToModelTransformer implements ElasticaToModelTransformerInterface +{ + /** + * PropertyAccessor instance. + * + * @var PropertyAccessorInterface + */ + protected $propertyAccessor; + + /** + * Set the PropertyAccessor instance. + * + * @param PropertyAccessorInterface $propertyAccessor + */ + public function setPropertyAccessor(PropertyAccessorInterface $propertyAccessor) + { + $this->propertyAccessor = $propertyAccessor; + } + + /** + * Returns a sorting closure to be used with usort() to put retrieved objects + * back in the order that they were returned by ElasticSearch. + * + * @param array $idPos + * @param string $identifierPath + * @return callable + */ + protected function getSortingClosure(array $idPos, $identifierPath) + { + $propertyAccessor = $this->propertyAccessor; + + return function ($a, $b) use ($idPos, $identifierPath, $propertyAccessor) { + return $idPos[$propertyAccessor->getValue($a, $identifierPath)] > $idPos[$propertyAccessor->getValue($b, $identifierPath)]; + }; + } +} diff --git a/Transformer/ElasticaToModelTransformerCollection.php b/Transformer/ElasticaToModelTransformerCollection.php index 8f750c5..9920f43 100644 --- a/Transformer/ElasticaToModelTransformerCollection.php +++ b/Transformer/ElasticaToModelTransformerCollection.php @@ -3,7 +3,7 @@ namespace FOS\ElasticaBundle\Transformer; use FOS\ElasticaBundle\HybridResult; -use Elastica_Document; +use Elastica\Document; /** * Holds a collection of transformers for an index wide transformation. @@ -40,7 +40,8 @@ class ElasticaToModelTransformerCollection implements ElasticaToModelTransformer } /** - * @param Elastica_Document[] $elasticaObjects + * @param Document[] $elasticaObjects + * * @return array */ public function transform(array $elasticaObjects) @@ -51,12 +52,12 @@ class ElasticaToModelTransformerCollection implements ElasticaToModelTransformer } $transformed = array(); - foreach ($sorted AS $type => $objects) { + foreach ($sorted as $type => $objects) { $transformedObjects = $this->transformers[$type]->transform($objects); - $identifierGetter = 'get' . ucfirst($this->transformers[$type]->getIdentifierField()); + $identifierGetter = 'get'.ucfirst($this->transformers[$type]->getIdentifierField()); $transformed[$type] = array_combine( array_map( - function($o) use ($identifierGetter) { + function ($o) use ($identifierGetter) { return $o->$identifierGetter(); }, $transformedObjects @@ -67,7 +68,9 @@ class ElasticaToModelTransformerCollection implements ElasticaToModelTransformer $result = array(); foreach ($elasticaObjects as $object) { - $result[] = $transformed[$object->getType()][$object->getId()]; + if (array_key_exists($object->getId(), $transformed[$object->getType()])) { + $result[] = $transformed[$object->getType()][$object->getId()]; + } } return $result; @@ -78,17 +81,10 @@ class ElasticaToModelTransformerCollection implements ElasticaToModelTransformer $objects = $this->transform($elasticaObjects); $result = array(); - for ($i = 0; $i < count($elasticaObjects); $i++) { + for ($i = 0, $j = count($elasticaObjects); $i < $j; $i++) { $result[] = new HybridResult($elasticaObjects[$i], $objects[$i]); } return $result; } - - protected function getTypeToClassMap() - { - return array_map(function (ElasticaToModelTransformerInterface $transformer) { - return $transformer->getObjectClass(); - }, $this->transformers); - } } diff --git a/Transformer/ElasticaToModelTransformerInterface.php b/Transformer/ElasticaToModelTransformerInterface.php index 5635ef3..71cd651 100644 --- a/Transformer/ElasticaToModelTransformerInterface.php +++ b/Transformer/ElasticaToModelTransformerInterface.php @@ -3,32 +3,33 @@ namespace FOS\ElasticaBundle\Transformer; /** - * Maps Elastica documents with model objects + * Maps Elastica documents with model objects. */ interface ElasticaToModelTransformerInterface { /** * Transforms an array of elastica objects into an array of - * model objects fetched from the doctrine repository + * model objects fetched from the doctrine repository. * * @param array $elasticaObjects array of elastica objects + * * @return array of model objects **/ - function transform(array $elasticaObjects); + public function transform(array $elasticaObjects); - function hybridTransform(array $elasticaObjects); + public function hybridTransform(array $elasticaObjects); /** * Returns the object class used by the transformer. * * @return string */ - function getObjectClass(); + public function getObjectClass(); /** - * Returns the identifier field from the options + * Returns the identifier field from the options. * * @return string the identifier field */ - function getIdentifierField(); + public function getIdentifierField(); } diff --git a/Transformer/HighlightableModelInterface.php b/Transformer/HighlightableModelInterface.php index d55407e..96c6c7c 100644 --- a/Transformer/HighlightableModelInterface.php +++ b/Transformer/HighlightableModelInterface.php @@ -1,16 +1,23 @@ - 'id' + 'identifier' => 'id', ); /** - * PropertyAccessor instance (will be used if available) + * PropertyAccessor instance. * * @var PropertyAccessorInterface */ - private $propertyAccessor; + protected $propertyAccessor; /** - * Instanciates a new Mapper + * Instanciates a new Mapper. * - * @param array $options + * @param array $options + * @param EventDispatcherInterface $dispatcher */ - public function __construct(array $options = array()) + public function __construct(array $options = array(), EventDispatcherInterface $dispatcher = null) { $this->options = array_merge($this->options, $options); + $this->dispatcher = $dispatcher; } /** - * Set the PropertyAccessor + * Set the PropertyAccessor. * * @param PropertyAccessorInterface $propertyAccessor */ - public function setPropertyAccessor(PropertyAccessorInterface $propertyAccessor = null) + public function setPropertyAccessor(PropertyAccessorInterface $propertyAccessor) { $this->propertyAccessor = $propertyAccessor; } /** - * Transforms an object into an elastica object having the required keys + * Transforms an object into an elastica object having the required keys. * * @param object $object the object to convert * @param array $fields the keys we want to have in the returned array * - * @return \Elastica_Document + * @return Document **/ public function transform($object, array $fields) { - $identifier = $this->getPropertyValue($object, $this->options['identifier']); - $document = new \Elastica_Document($identifier); + $identifier = $this->propertyAccessor->getValue($object, $this->options['identifier']); + $document = new Document($identifier); foreach ($fields as $key => $mapping) { - $value = $this->getPropertyValue($object, $key); + if ($key == '_parent') { + $property = (null !== $mapping['property']) ? $mapping['property'] : $mapping['type']; + $value = $this->propertyAccessor->getValue($object, $property); + $document->setParent($this->propertyAccessor->getValue($value, $mapping['identifier'])); - if (isset($mapping['_parent']['identifier'])) { - /* $value is the parent. Read its identifier and set that as the - * document's parent. - */ - $document->setParent($this->getPropertyValue($value, $mapping['_parent']['identifier'])); continue; } - if (isset($mapping['type']) && in_array($mapping['type'], array('nested', 'object'))) { + $path = isset($mapping['property_path']) ? + $mapping['property_path'] : + $key; + if (false === $path) { + continue; + } + $value = $this->propertyAccessor->getValue($object, $path); + + if (isset($mapping['type']) && in_array($mapping['type'], array('nested', 'object')) && isset($mapping['properties']) && !empty($mapping['properties'])) { /* $value is a nested document or object. Transform $value into * an array of documents, respective the mapped properties. */ - $document->add($key, $this->transformNested($value, $mapping['properties'], $document)); + $document->set($key, $this->transformNested($value, $mapping['properties'])); + continue; } @@ -87,40 +103,28 @@ class ModelToElasticaAutoTransformer implements ModelToElasticaTransformerInterf } else { $document->addFileContent($key, $value); } + continue; } - $document->add($key, $this->normalizeValue($value)); + $document->set($key, $this->normalizeValue($value)); + } + + if ($this->dispatcher) { + $event = new TransformEvent($document, $fields, $object); + $this->dispatcher->dispatch(TransformEvent::POST_TRANSFORM, $event); + + $document = $event->getDocument(); } return $document; } /** - * Get the value of an object property. - * - * This method will use Symfony 2.2's PropertyAccessor if it is available. - * - * @param object $object - * @param string $property - * @return mixed - */ - protected function getPropertyValue($object, $property) - { - if (isset($this->propertyAccessor)) { - return $this->propertyAccessor->getValue($object, $property); - } - - $propertyPath = new PropertyPath($property); - - return $propertyPath->getValue($object); - } - - /** - * transform a nested document or an object property into an array of ElasticaDocument + * transform a nested document or an object property into an array of ElasticaDocument. * * @param array|\Traversable|\ArrayAccess $objects the object to convert - * @param array $fields the keys we want to have in the returned array + * @param array $fields the keys we want to have in the returned array * * @return array */ @@ -144,7 +148,7 @@ class ModelToElasticaAutoTransformer implements ModelToElasticaTransformerInterf } /** - * Attempts to convert any type to a string or an array of strings + * Attempts to convert any type to a string or an array of strings. * * @param mixed $value * @@ -152,17 +156,16 @@ class ModelToElasticaAutoTransformer implements ModelToElasticaTransformerInterf */ protected function normalizeValue($value) { - $normalizeValue = function(&$v) - { + $normalizeValue = function (&$v) { if ($v instanceof \DateTime) { $v = $v->format('c'); } elseif (!is_scalar($v) && !is_null($v)) { - $v = (string)$v; + $v = (string) $v; } }; if (is_array($value) || $value instanceof \Traversable || $value instanceof \ArrayAccess) { - $value = is_array($value) ? $value : iterator_to_array($value); + $value = is_array($value) ? $value : iterator_to_array($value, false); array_walk_recursive($value, $normalizeValue); } else { $normalizeValue($value); @@ -170,5 +173,4 @@ class ModelToElasticaAutoTransformer implements ModelToElasticaTransformerInterf return $value; } - } diff --git a/Transformer/ModelToElasticaIdentifierTransformer.php b/Transformer/ModelToElasticaIdentifierTransformer.php new file mode 100644 index 0000000..6301be1 --- /dev/null +++ b/Transformer/ModelToElasticaIdentifierTransformer.php @@ -0,0 +1,27 @@ +propertyAccessor->getValue($object, $this->options['identifier']); + + return new Document($identifier); + } +} diff --git a/Transformer/ModelToElasticaTransformerInterface.php b/Transformer/ModelToElasticaTransformerInterface.php index 924bc7c..0ad9f12 100644 --- a/Transformer/ModelToElasticaTransformerInterface.php +++ b/Transformer/ModelToElasticaTransformerInterface.php @@ -3,16 +3,17 @@ namespace FOS\ElasticaBundle\Transformer; /** - * Maps Elastica documents with model objects + * Maps Elastica documents with model objects. */ interface ModelToElasticaTransformerInterface { /** - * Transforms an object into an elastica object having the required keys + * Transforms an object into an elastica object having the required keys. * * @param object $object the object to convert - * @param array $fields the keys we want to have in the returned array - * @return \Elastica_Document + * @param array $fields the keys we want to have in the returned array + * + * @return \Elastica\Document **/ - function transform($object, array $fields); + public function transform($object, array $fields); } diff --git a/UPGRADE-3.0.md b/UPGRADE-3.0.md new file mode 100644 index 0000000..a9a99a4 --- /dev/null +++ b/UPGRADE-3.0.md @@ -0,0 +1,32 @@ +UPGRADE FROM 2.1 to 3.0 +======================= + +### Serialization + + * you can now define a Serializer service and callback for indexing. All providers and listeners will use it. + + ```yml + serializer: + callback_class: FOS\ElasticaBundle\Serializer\Callback + serializer: serializer + ``` + +### Mapping + + * you do not have to setup any mapping anymore if you use a Serializer, properties are no more indexed only if + they are mapped. So this kind of configuration became valid: + + ```yml + serializer: + callback_class: FOS\ElasticaBundle\Serializer\Callback + serializer: serializer + indexes: + acme: + client: default + types: + Article: + persistence: + driver: orm + model: Acme\Bundle\CoreBundle\Entity\Article + provider: ~ + ``` diff --git a/composer.json b/composer.json index a4969a0..9705a04 100644 --- a/composer.json +++ b/composer.json @@ -6,34 +6,39 @@ "homepage": "https://github.com/FriendsOfSymfony/FOSElasticaBundle", "license": "MIT", "authors": [ - { "name": "Thibault Duplessis", "email": "thibault.duplessis@gmail.com" }, + { "name": "FriendsOfSymfony Community", "homepage": "https://github.com/FriendsOfSymfony/FOSElasticaBundle/contributors" }, + { "name": "Tim Nagel", "email": "tim@nagel.com.au" }, { "name": "Richard Miller", "email": "richard.miller@limethinking.co.uk" }, { "name": "Jeremy Mikola", "email": "jmikola@gmail.com" } ], "require": { "php": ">=5.3.2", - "symfony/framework-bundle": ">=2.1.0,<2.3.0-dev", - "symfony/console": ">=2.1.0,<2.3.0-dev", - "symfony/form": ">=2.1.0,<2.3.0-dev", - "ruflin/elastica": "0.19.8" + "symfony/framework-bundle": "~2.3", + "symfony/console": "~2.1", + "symfony/form": "~2.1", + "symfony/property-access": "~2.2", + "ruflin/elastica": ">=0.90.10.0, <1.5-dev", + "psr/log": "~1.0" }, "require-dev":{ - "doctrine/orm": ">=2.2,<2.5-dev", - "doctrine/mongodb-odm": "1.0.*@dev", + "doctrine/orm": "~2.4", + "doctrine/doctrine-bundle": "~1.2", + "jms/serializer-bundle": "@stable", + "phpunit/phpunit": "~4.1", "propel/propel1": "1.6.*", - "pagerfanta/pagerfanta": "1.0.*@dev", - "knplabs/knp-components": "1.2.*" - }, - "suggest": { - "symfony/property-access": "2.2.*", - "doctrine/orm": ">=2.2,<2.5-dev", - "doctrine/mongodb-odm": "1.0.*@dev", - "propel/propel1": "1.6.*", - "pagerfanta/pagerfanta": "1.0.*@dev", - "knplabs/knp-components": "1.2.*" + "pagerfanta/pagerfanta": "~1.0", + "knplabs/knp-components": "~1.2", + "knplabs/knp-paginator-bundle": "~2.4", + "symfony/browser-kit" : "~2.3", + "symfony/expression-language" : "~2.4", + "symfony/twig-bundle": "~2.3" }, "autoload": { - "psr-0": { "FOS\\ElasticaBundle": "" } + "psr-4": { "FOS\\ElasticaBundle\\": "" } }, - "target-dir": "FOS/ElasticaBundle" + "extra": { + "branch-alias": { + "dev-master": "3.2.x-dev" + } + } } diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 005cfd8..799d5bf 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,6 +1,6 @@ - + ./Tests