diff --git a/app/config/config.yml b/app/config/config.yml
index 364f0f6e4d..5d35085e2e 100644
--- a/app/config/config.yml
+++ b/app/config/config.yml
@@ -80,3 +80,46 @@ swiftmailer:
username: %sylius.mailer.user%
password: %sylius.mailer.password%
spool: { type: memory }
+
+sylius_search:
+ form: 'SyliusSearchBundle::form.html.twig'
+ items_per_page: 9
+ driver: orm
+ orm_indexes:
+ product:
+ class: Sylius\Component\Core\Model\Product
+ mappings:
+ id: ~
+ name: ~
+ description: ~
+ filters:
+ search_filter:
+ enabled: true
+ taxonomy: category
+ facet_groups:
+ search_set:
+ values: [taxons, price, made_of, color]
+ categories_set:
+ values: [price, made_of, color]
+ all_set:
+ values: [taxons, price, made_of]
+ facets:
+ taxons:
+ display_name: 'Basic categories'
+ type: terms
+ value: ~
+ price:
+ display_name: 'Available prices'
+ type: range
+ values:
+ - { from: 0, to: 2000}
+ - { from: 2001, to: 5000}
+ - { from: 5001, to: 10000}
+ made_of:
+ display_name: 'Material'
+ type: terms
+ value: ~
+ color:
+ display_name: 'Available colors'
+ type: terms
+ value: ~
diff --git a/behat.yml.dist b/behat.yml.dist
index d7d32de6f9..eb935458ee 100644
--- a/behat.yml.dist
+++ b/behat.yml.dist
@@ -271,6 +271,20 @@ default:
filters:
tags: "@javascript"
+ search:
+ contexts:
+ - Sylius\Bundle\SearchBundle\Behat\SearchContext
+ - Behat\MinkExtension\Context\MinkContext
+ - Sylius\Bundle\AddressingBundle\Behat\AddressingContext
+ - Sylius\Bundle\CoreBundle\Behat\CoreContext
+ - Sylius\Bundle\ProductBundle\Behat\ProductContext
+ - Sylius\Bundle\ResourceBundle\Behat\BaseContext
+ - Sylius\Bundle\ShippingBundle\Behat\ShippingContext
+ - Sylius\Bundle\WebBundle\Behat\WebContext
+ - Sylius\Bundle\MoneyBundle\Behat\MoneyContext
+ filters:
+ tags: "@search"
+
extensions:
Behat\MinkExtension:
sessions:
diff --git a/composer.json b/composer.json
index aeebaf485e..1ca108f29a 100644
--- a/composer.json
+++ b/composer.json
@@ -58,7 +58,8 @@
"white-october/pagerfanta-bundle": "~1.0",
"winzou/state-machine-bundle": "~0.2",
"willdurand/hateoas-bundle": "1.0.*@dev",
- "friendsofsymfony/oauth-server-bundle": "dev-master"
+ "friendsofsymfony/oauth-server-bundle": "dev-master",
+ "friendsofsymfony/elastica-bundle": "~3.0.2"
},
"require-dev": {
"doctrine/doctrine-fixtures-bundle": "~2.2",
@@ -111,6 +112,8 @@
"sylius/registry": "self.version",
"sylius/resource": "self.version",
"sylius/resource-bundle": "self.version",
+ "sylius/search": "self.version",
+ "sylius/search-bundle": "self.version",
"sylius/sequence": "self.version",
"sylius/sequence-bundle": "self.version",
"sylius/settings-bundle": "self.version",
diff --git a/features/backend/search.feature b/features/backend/search.feature
new file mode 100644
index 0000000000..4d3f7ffe17
--- /dev/null
+++ b/features/backend/search.feature
@@ -0,0 +1,53 @@
+@search_orm_only
+Feature: Orm indexer event listener
+ In order to have a consistent indexing when orm is enabled
+ As a administrator
+ I want to update the index when a product change occurs
+
+ Background:
+ Given there is default currency configured
+ And I am logged in as administrator
+ And there are following taxonomies defined:
+ | name |
+ | Category |
+ And taxonomy "Category" has following taxons:
+ | Clothing > T-Shirts |
+ | Clothing > PHP T-Shirts |
+ | Clothing > Gloves |
+ And the following products exist:
+ | name | price | taxons | description |
+ | Super T-Shirt | 19.99 | T-Shirts | super black t-shirt |
+ | Black T-Shirt | 18.99 | T-Shirts | black t-shirt |
+ | Sylius Tee | 12.99 | PHP T-Shirts | a very nice php t-shirt |
+ | Symfony T-Shirt | 15.00 | PHP T-Shirts | symfony t-shirt |
+ | Doctrine T-Shirt | 15.00 | PHP T-Shirts | doctrine t-shirt |
+
+ Scenario: Viewing the dashboard at website root
+ Given I am on the dashboard page
+ Then I should see "Administration dashboard"
+
+ Scenario: Creating simple product and indexing it
+ Given I am on the product creation page
+ When I fill in the following:
+ | Name | Index test product |
+ | Description | Interesting description |
+ | Price | 29.99 |
+ And I press "Create"
+ Then I should be on the page of product "Index test product"
+ And I should see "Product has been successfully created."
+ And I should find an indexed entry for "Interesting description"
+
+ Scenario: Updating the product description and update the index accordingly
+ Given I am editing product "Sylius Tee"
+ When I fill in "Description" with "Another description"
+ And I press "Save changes"
+ Then I should be on the page of product "Sylius Tee"
+ And I should see "Another description"
+ And I should find an indexed entry for "Another description"
+
+ Scenario: Deleting product should delete the index as well
+ Given I am on the page of product "Sylius Tee"
+ When I press "delete"
+ Then I should be on the product index page
+ And I should see "Product has been successfully deleted."
+ And I should not find an indexed entry for "a very nice php t-shirt"
\ No newline at end of file
diff --git a/features/frontend/search.feature b/features/frontend/search.feature
new file mode 100644
index 0000000000..30dcda62f8
--- /dev/null
+++ b/features/frontend/search.feature
@@ -0,0 +1,70 @@
+@search
+Feature: Search products
+ In order to be able to find products
+ As a visitor
+ I want to be able to search the products
+
+ Background:
+ Given there is default currency configured
+ And there are following taxonomies defined:
+ | name |
+ | Category |
+ And taxonomy "Category" has following taxons:
+ | Clothing > T-Shirts |
+ | Clothing > PHP T-Shirts |
+ | Clothing > Gloves |
+ And the following products exist:
+ | name | price | taxons | description |
+ | Super T-Shirt | 19.99 | T-Shirts | super black t-shirt |
+ | Black T-Shirt | 18.99 | T-Shirts | black t-shirt |
+ | Sylius Tee | 12.99 | PHP T-Shirts | a very nice php t-shirt |
+ | Symfony T-Shirt | 15.00 | PHP T-Shirts | symfony t-shirt |
+ | Doctrine T-Shirt | 15.00 | PHP T-Shirts | doctrine t-shirt |
+ And I populate the index
+
+ Scenario: Search homepage is accessible
+ Given I am on homepage
+ Then I should see "Login"
+ And the response status code should be 200
+
+ Scenario: Search for a product
+ Given I am on homepage
+ When I fill in "search" with "black"
+ And I press "search-button"
+ Then I should be on "/search/"
+ And I should see "black"
+ And I should see "T-Shirts (2)"
+ And I should see "€0.00 to €20.00 (2)"
+
+ Scenario: Apply filters to a search result
+ Given I am on homepage
+ When I fill in "search" with "black"
+ And I press "search-button"
+ Then I should be on "/search/"
+ And I should see "black"
+ When I select the "price-0"
+ And I press "Filter"
+ Then I should see "€0.00 to €20.00 (2)"
+
+ Scenario: Get facets for a taxon
+ Given I am on homepage
+ And I follow "T-Shirts"
+ Then I should see there 2 products
+ And I should see "€0.00 to €20.00 (2)"
+
+ Scenario: Apply filters on a taxon
+ Given I am on homepage
+ And I follow "T-Shirts"
+ Then I should see there 2 products
+ And I should see "€0.00 to €20.00 (2)"
+ When I select the "price-0"
+ And I press "Filter"
+ Then I should see "€0.00 to €20.00 (2)"
+
+ Scenario: If I search for something that does not exists I should see a no products message
+ Given I am on homepage
+ When I fill in "search" with "crazy frog"
+ And I press "search-button"
+ Then I should be on "/search/"
+ And I should see "There are no products to display"
+
diff --git a/phpspec.yml b/phpspec.yml
index a452027a4c..2d0841c534 100644
--- a/phpspec.yml
+++ b/phpspec.yml
@@ -42,6 +42,7 @@ suites:
ProductBundle : { namespace: Sylius, spec_path: src/Sylius/Bundle/ProductBundle }
PromotionBundle : { namespace: Sylius, spec_path: src/Sylius/Bundle/PromotionBundle }
ResourceBundle : { namespace: Sylius, spec_path: src/Sylius/Bundle/ResourceBundle }
+ SearchBundle : { namespace: Sylius, spec_path: src/Sylius/Bundle/SearchBundle }
SequenceBundle : { namespace: Sylius, spec_path: src/Sylius/Bundle/SequenceBundle }
SettingsBundle : { namespace: Sylius, spec_path: src/Sylius/Bundle/SettingsBundle }
ShippingBundle : { namespace: Sylius, spec_path: src/Sylius/Bundle/ShippingBundle }
diff --git a/src/Sylius/Bundle/CoreBundle/Controller/ProductController.php b/src/Sylius/Bundle/CoreBundle/Controller/ProductController.php
index c3e403c265..be2b0559e5 100644
--- a/src/Sylius/Bundle/CoreBundle/Controller/ProductController.php
+++ b/src/Sylius/Bundle/CoreBundle/Controller/ProductController.php
@@ -19,6 +19,7 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
+use Sylius\Bundle\SearchBundle\Query\TaxonQuery;
/**
* Product controller.
@@ -39,6 +40,9 @@ class ProductController extends ResourceController
*/
public function indexByTaxonAction(Request $request, $permalink)
{
+ $criteria = $request->get('sylius_filter_form');
+ unset($criteria['_token'], $criteria['filter']);
+
if ($request->attributes->has('_sylius_entity')) {
$taxon = $request->attributes->get('_sylius_entity');
} else {
@@ -50,12 +54,15 @@ class ProductController extends ResourceController
}
}
- $paginator = $this
- ->getRepository()
- ->createByTaxonPaginator($taxon)
- ;
+ $finder = $this->get('sylius_search.finder')
+ ->setFacetGroup('categories_set')
+ ->find(new TaxonQuery($taxon, $request->query->get('filters')));
- return $this->renderResults($taxon, $paginator, 'indexByTaxon.html', $request->get('page', 1));
+ $config = $this->container->getParameter("sylius_search.config");
+
+ $paginator = $finder->getPaginator();
+
+ return $this->renderResults($taxon, $paginator, 'indexByTaxon.html', $request->get('page', 1), $finder->getFacets(), $config['filters']['facets']);
}
/**
@@ -182,7 +189,7 @@ class ProductController extends ResourceController
return parent::findOr404($request, $criteria);
}
- private function renderResults(TaxonInterface $taxon, Pagerfanta $results, $template, $page)
+ private function renderResults(TaxonInterface $taxon, Pagerfanta $results, $template, $page, $facets = null, $facetTags = null)
{
$results->setCurrentPage($page, true, true);
$results->setMaxPerPage($this->config->getPaginationMaxPerPage());
@@ -193,6 +200,8 @@ class ProductController extends ResourceController
->setData(array(
'taxon' => $taxon,
'products' => $results,
+ 'facets' => $facets,
+ 'facetTags' => $facetTags,
))
;
diff --git a/src/Sylius/Bundle/CoreBundle/Kernel/Kernel.php b/src/Sylius/Bundle/CoreBundle/Kernel/Kernel.php
index 97b3487b80..f65fafb397 100644
--- a/src/Sylius/Bundle/CoreBundle/Kernel/Kernel.php
+++ b/src/Sylius/Bundle/CoreBundle/Kernel/Kernel.php
@@ -57,6 +57,7 @@ abstract class Kernel extends BaseKernel
new \Sylius\Bundle\PricingBundle\SyliusPricingBundle(),
new \Sylius\Bundle\SequenceBundle\SyliusSequenceBundle(),
new \Sylius\Bundle\ContentBundle\SyliusContentBundle(),
+ new \Sylius\Bundle\SearchBundle\SyliusSearchBundle(),
new \Sylius\Bundle\CoreBundle\SyliusCoreBundle(),
new \Sylius\Bundle\WebBundle\SyliusWebBundle(),
@@ -88,6 +89,7 @@ abstract class Kernel extends BaseKernel
new \FOS\RestBundle\FOSRestBundle(),
new \FOS\UserBundle\FOSUserBundle(),
+ new \FOS\ElasticaBundle\FOSElasticaBundle(),
new \Knp\Bundle\GaufretteBundle\KnpGaufretteBundle(),
new \Knp\Bundle\MenuBundle\KnpMenuBundle(),
new \Knp\Bundle\SnappyBundle\KnpSnappyBundle(),
diff --git a/src/Sylius/Bundle/ProductBundle/Behat/ProductContext.php b/src/Sylius/Bundle/ProductBundle/Behat/ProductContext.php
index 668f722385..f6a5c27835 100644
--- a/src/Sylius/Bundle/ProductBundle/Behat/ProductContext.php
+++ b/src/Sylius/Bundle/ProductBundle/Behat/ProductContext.php
@@ -43,6 +43,11 @@ class ProductContext extends DefaultContext
if (isset($data['sku'])) {
$product->setSku($data['sku']);
}
+
+ if (isset($data['description'])) {
+ $product->setDescription($data['description']);
+ }
+
if (isset($data['quantity'])) {
$product->getMasterVariant()->setOnHand($data['quantity']);
}
diff --git a/src/Sylius/Bundle/SearchBundle/.gitignore b/src/Sylius/Bundle/SearchBundle/.gitignore
new file mode 100644
index 0000000000..46aed33710
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/.gitignore
@@ -0,0 +1,5 @@
+vendor/
+bin/
+
+composer.phar
+composer.lock
diff --git a/src/Sylius/Bundle/SearchBundle/.travis.yml b/src/Sylius/Bundle/SearchBundle/.travis.yml
new file mode 100644
index 0000000000..1c1e3af42d
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/.travis.yml
@@ -0,0 +1,14 @@
+language: php
+
+php:
+ - 5.3
+ - 5.4
+ - 5.5
+
+before_script: composer install --prefer-source --no-interaction
+
+script: bin/phpspec run -fpretty --verbose
+
+notifications:
+ email: "travis-ci@sylius.org"
+ irc: "irc.freenode.org#sylius-dev"
diff --git a/src/Sylius/Bundle/SearchBundle/Accessor/ProductAccessor.php b/src/Sylius/Bundle/SearchBundle/Accessor/ProductAccessor.php
new file mode 100644
index 0000000000..3dcb65e862
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/Accessor/ProductAccessor.php
@@ -0,0 +1,63 @@
+
+ */
+class ProductAccessor extends PropertyAccessor
+{
+ /**
+ * @param array|object $objectOrArray
+ * @param string|PropertyPathInterface $propertyPath
+ *
+ * @return array|mixed
+ */
+ public function getValue($objectOrArray, $propertyPath)
+ {
+ try {
+ return parent::getValue($objectOrArray, $propertyPath);
+ }catch (Exception\NoSuchPropertyException $e){
+ $tags = array();
+
+ foreach ($objectOrArray->getAvailableVariants() as $variant) {
+ foreach ($variant->getOptions() as $option) {
+
+ if (strtolower($option->getPresentation()) == strtolower($propertyPath)) {
+ $tags[] = $option->getValue();
+ }
+ }
+ }
+
+ foreach ($objectOrArray->getAttributes() as $attribute) {
+
+ if (strtolower(str_replace(' ', '_', $attribute->getPresentation())) == strtolower($propertyPath)) {
+ $tags[] = $attribute->getValue();
+ }
+ }
+
+ return array_values(array_unique($tags));
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Sylius/Bundle/SearchBundle/Behat/SearchContext.php b/src/Sylius/Bundle/SearchBundle/Behat/SearchContext.php
new file mode 100644
index 0000000000..2151fbf892
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/Behat/SearchContext.php
@@ -0,0 +1,104 @@
+
+ */
+class SearchContext extends DefaultContext
+{
+
+ /**
+ * @Given /^I populate the index$/
+ */
+ public function iPopulateTheIndex()
+ {
+ $command = $this->kernel->getRootDir() . "/console sylius:search:index --env=test";
+ $process = new Process($command);
+ $process->run();
+
+ if (!$process->isSuccessful()) {
+ throw new \RuntimeException($process->getErrorOutput());
+ }
+
+ print $process->getOutput();
+ }
+
+ /**
+ * @Given /^I select the "([^""]*)"$/
+ */
+ public function iSelectThe($id)
+ {
+ $html = $this->getSession()->getPage()->getHtml();
+
+ $crawler = new Crawler($html);
+
+ $element = $crawler->filter('#'.$id)->first();
+
+ $element->addHtmlContent('checked');
+ }
+
+ /**
+ * @Given /^I should find an indexed entry for "([^""]*)"$/
+ */
+ public function iCreateAndIndex($id)
+ {
+ $em = $this->getContainer()->get('doctrine')->getManager();
+ $queryBuilder = $em->createQueryBuilder();
+ $queryBuilder
+ ->select('u')
+ ->from('Sylius\Bundle\SearchBundle\Entity\SyliusSearchIndex', 'u')
+ ->where('u.value LIKE :id')
+ ->setParameter('id', '%'.$id.'%')
+ ;
+
+ $result = $queryBuilder->getQuery()->getResult();
+
+ if (!$result) {
+ throw new Exception(
+ "The entry does not exist in the index"
+ );
+ }else{
+ return true;
+ }
+ }
+
+ /**
+ * @Given /^I should not find an indexed entry for "([^""]*)"$/
+ */
+ public function iDeleteAnIndex($id)
+ {
+ $em = $this->getContainer()->get('doctrine')->getManager();
+ $queryBuilder = $em->createQueryBuilder();
+ $queryBuilder
+ ->select('u')
+ ->from('Sylius\Bundle\SearchBundle\Entity\SyliusSearchIndex', 'u')
+ ->where('u.value LIKE :id')
+ ->setParameter('id', '%'.$id.'%')
+ ;
+
+ $result = $queryBuilder->getQuery()->getResult();
+
+ if (!empty($result)) {
+ throw new Exception(
+ "The entry does exist in the index"
+ );
+ }else{
+ return true;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Sylius/Bundle/SearchBundle/CHANGELOG.md b/src/Sylius/Bundle/SearchBundle/CHANGELOG.md
new file mode 100644
index 0000000000..a841eee864
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/CHANGELOG.md
@@ -0,0 +1,4 @@
+CHANGELOG
+=========
+
+* Initial dev release.
diff --git a/src/Sylius/Bundle/SearchBundle/Command/IndexCommand.php b/src/Sylius/Bundle/SearchBundle/Command/IndexCommand.php
new file mode 100644
index 0000000000..840519b68d
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/Command/IndexCommand.php
@@ -0,0 +1,42 @@
+
+ */
+class IndexCommand extends ContainerAwareCommand
+{
+ protected function configure()
+ {
+ $this
+ ->setName('sylius:search:index')
+ ->setDescription('Populate the index')
+ ;
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output)
+ {
+ $output->writeln('Index populate command');
+
+ $this->getContainer()->get('sylius_search.command')->populate($this->getContainer()->get('doctrine.orm.entity_manager'));
+
+ $output->writeln('Done');
+ }
+
+}
diff --git a/src/Sylius/Bundle/SearchBundle/Controller/SearchController.php b/src/Sylius/Bundle/SearchBundle/Controller/SearchController.php
new file mode 100644
index 0000000000..7e63589bb8
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/Controller/SearchController.php
@@ -0,0 +1,133 @@
+
+ */
+class SearchController extends Controller
+{
+
+ /**
+ * Search landing page.
+ *
+ * @param \Symfony\Component\HttpFoundation\Request $request
+ *
+ * @return Response
+ */
+ public function indexAction(Request $request)
+ {
+ $finder = $this->get('sylius_search.finder')
+ ->setTargetIndex('product')
+ ->setFacetGroup('search_set')
+ ->find(new SearchStringQuery(
+ $request->query->get('q'),
+ $request->query->get('search_param'),
+ $request->query->get('filters'),
+ $this->container->getParameter('sylius_search.filter.enabled')
+ )
+ );
+
+ $selectedFilters = array();
+ if (is_array($request->query->get('filters'))) {
+ $selectedFilters = $request->query->get('filters');
+ }
+
+ $paginator = $finder->getPaginator();
+ $facets = $finder->getFacets();
+
+ $config = $this->container->getParameter("sylius_search.config");
+
+ if (isset($paginator)) {
+ $paginator->setMaxPerPage($config['items_per_page']);
+ $paginator->setCurrentPage($request->query->get('page', 1));
+ }
+
+ $this->logSearchString(
+ $request->query->get('q'),
+ $request->headers->get('User-Agent'),
+ $request->getClientIp()
+ );
+
+ return $this->render('SyliusSearchBundle::search.html.twig', array(
+ 'results' => $paginator,
+ 'facets' => $facets,
+ 'selectedFilters' => $selectedFilters,
+ 'facetTags' => $config['filters']['facets'],
+ ));
+ }
+
+ /**
+ * @param Request $request
+ *
+ * @return Response
+ */
+ public function formAction(Request $request)
+ {
+ $filters = array();
+
+ if ($this->container->getParameter('sylius_search.filter.enabled')) {
+ $taxonomy = $this->get('sylius.repository.taxonomy')
+ ->findOneBy(
+ array(
+ 'name' => strtoupper($this->container->getParameter('sylius_search.filter.taxonomy'))
+ )
+ );
+
+ $filters = array();
+ if (!empty($taxonomy)) {
+ foreach ($taxonomy->getTaxons() as $taxon) {
+ $filters[] = $taxon->getName();
+ }
+ }
+
+ }
+
+ return $this->render($this->container->getParameter('sylius_search.form'), array(
+ 'filters' => $filters,
+ 'searchTerm' => $request->query->get('q'),
+ 'searchParam' => $request->query->get('search_param'),
+ ));
+ }
+
+ /**
+ * Logs a search
+ *
+ * TODO: I could move this to a listener hooked in a find event
+ *
+ * @param $searchString
+ * @param $userAgent
+ * @param $remoteAddress
+ *
+ * @internal param $request
+ */
+ private function logSearchString($searchString, $userAgent, $remoteAddress)
+ {
+ $searchLog = new SearchLog();
+
+ $searchLog->setSearchString($searchString);
+ $searchLog->setClient($userAgent);
+ $searchLog->setRemoteAddress($remoteAddress);
+
+ $em = $this->getDoctrine()->getManager();
+ $em->persist($searchLog);
+ $em->flush();
+ }
+}
diff --git a/src/Sylius/Bundle/SearchBundle/DependencyInjection/Configuration.php b/src/Sylius/Bundle/SearchBundle/DependencyInjection/Configuration.php
new file mode 100644
index 0000000000..bc8cbff96f
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/DependencyInjection/Configuration.php
@@ -0,0 +1,163 @@
+
+ */
+class Configuration implements ConfigurationInterface
+{
+ /**
+ * {@inheritDoc}
+ */
+ public function getConfigTreeBuilder()
+ {
+ $treeBuilder = new TreeBuilder();
+ $rootNode = $treeBuilder->root('sylius_search');
+
+ $this->addFormSection($rootNode);
+
+ $this->addFilterSection($rootNode);
+
+ $this->addDriverSection($rootNode);
+
+ $this->addIndexesSection($rootNode);
+
+ return $treeBuilder;
+ }
+
+ /**
+ * adds form section
+ *
+ * @param ArrayNodeDefinition $node
+ */
+ private function addFormSection(ArrayNodeDefinition $node)
+ {
+ $node
+ ->children()
+ ->scalarNode('form')
+ ->info('Define the search form')
+ ->end()
+ ->scalarNode('items_per_page')
+ ->info('Define paginated items')
+ ->end()
+ ->end()
+ ;
+ }
+
+ /**
+ * adds filter section
+ *
+ * @param ArrayNodeDefinition $node
+ */
+ private function addFilterSection(ArrayNodeDefinition $node)
+ {
+ $node
+ ->children()
+ ->arrayNode('filters')
+ ->children()
+ ->arrayNode('search_filter')
+ ->children()
+ ->scalarNode('enabled')->end()
+ ->scalarNode('taxonomy')->end()
+ ->end()
+ ->end()
+ ->arrayNode('finders')
+ ->useAttributeAsKey('name')
+ ->prototype('array')
+ ->children()
+ ->scalarNode('facet_group')->end()
+ ->end()
+ ->end()
+ ->end()
+ ->arrayNode('facet_groups')
+ ->useAttributeAsKey('name')
+ ->prototype('array')
+ ->children()
+ ->arrayNode('values')
+ ->useAttributeAsKey('name')
+ ->prototype('scalar')
+ ->end()
+ ->end()
+ ->end()
+ ->end()
+ ->end()
+ ->arrayNode('facets')
+ ->useAttributeAsKey('name')
+ ->prototype('array')
+ ->children()
+ ->scalarNode('display_name')->end()
+ ->scalarNode('type')->end()
+ ->scalarNode('value')->end()
+ ->arrayNode('values')
+ ->prototype('array')
+ ->children()
+ ->scalarNode('from')->end()
+ ->scalarNode('to')->end()
+ ->end()
+ ->end()
+ ->end()
+ ->end()
+ ->end()
+ ->end()
+ ->end()
+ ->end()
+ ->end()
+ ;
+ }
+
+ /**
+ * adds driver section
+ *
+ * @param ArrayNodeDefinition $node
+ */
+ private function addDriverSection(ArrayNodeDefinition $node)
+ {
+ $node
+ ->children()
+ ->scalarNode('driver')
+ ->info('Defaults to the first client defined')
+ ->end()
+ ->end()
+ ;
+ }
+
+ private function addIndexesSection(ArrayNodeDefinition $node)
+ {
+ $node
+ ->children()
+ ->arrayNode('orm_indexes')
+ ->useAttributeAsKey('name')
+ ->prototype('array')
+ ->children()
+ ->scalarNode('class')
+ ->end()
+ ->arrayNode('mappings')
+ ->prototype('array')
+ ->end()
+ ->end()
+ ->end()
+ ->end()
+ ->end()
+ ->end()
+ ;
+ }
+
+}
diff --git a/src/Sylius/Bundle/SearchBundle/DependencyInjection/SyliusSearchExtension.php b/src/Sylius/Bundle/SearchBundle/DependencyInjection/SyliusSearchExtension.php
new file mode 100644
index 0000000000..c15c8f0c64
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/DependencyInjection/SyliusSearchExtension.php
@@ -0,0 +1,54 @@
+
+ */
+class SyliusSearchExtension extends Extension
+{
+
+ public function load(array $configs, ContainerBuilder $container)
+ {
+ $configuration = new Configuration();
+ $config = $this->processConfiguration($configuration, $configs);
+
+ $loader = new Loader\XmlFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
+ $loader->load('services.xml');
+
+ $container->setParameter('search', $config['driver']);
+ $container->setParameter('sylius_search.config', $config);
+
+ $searchClass = 'Sylius\Bundle\SearchBundle\Searcher\\'.$config['driver'].'Searcher';
+ $finderClass = 'Sylius\Bundle\SearchBundle\Finder\\'.$config['driver'].'Finder';
+
+ $commandClass = sprintf('sylius.search.%s.indexer', $config['driver']);
+ $container->setAlias('sylius_search.command', $commandClass);
+
+ $container->setParameter('sylius_search.engine', $searchClass);
+ $container->setParameter('sylius_search.finder', $finderClass);
+ $container->setParameter('sylius_search.command', $commandClass);
+ $container->setParameter('sylius_search.form', $config['form']);
+ $container->setParameter('sylius_search.filter.enabled', $config['filters']['search_filter']['enabled']);
+ $container->setParameter('sylius_search.filter.taxonomy', $config['filters']['search_filter']['taxonomy']);
+ }
+
+}
\ No newline at end of file
diff --git a/src/Sylius/Bundle/SearchBundle/Doctrine/ORM/SyliusSearchIndexRepository.php b/src/Sylius/Bundle/SearchBundle/Doctrine/ORM/SyliusSearchIndexRepository.php
new file mode 100644
index 0000000000..0a52255284
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/Doctrine/ORM/SyliusSearchIndexRepository.php
@@ -0,0 +1,97 @@
+
+ */
+class SyliusSearchIndexRepository
+{
+ /**
+ * @var \Doctrine\ORM\EntityManager
+ */
+ private $em;
+
+ /**
+ * @var \Sylius\Bundle\ProductBundle\Doctrine\ORM\ProductRepository
+ */
+ private $productRepository;
+
+ /**
+ * @param EntityManager $em
+ * @param BaseProductRepository $productRepository
+ */
+ public function __construct(EntityManager $em, BaseProductRepository $productRepository)
+ {
+ $this->em = $em;
+ $this->productRepository = $productRepository;
+ }
+
+ /**
+ * Returns the product ids for a given taxon
+ *
+ * @param $taxonName
+ *
+ * @return array
+ */
+ public function getProductIdsFromTaxonName($taxonName)
+ {
+
+ // Gets the taxon ids
+ $query = $this->em->createQuery('SELECT u.id FROM Sylius\Component\Core\Model\Taxon u WHERE u.name = ?1');
+ $query->setParameter(1, $taxonName);
+ $taxonId = $query->getResult(Query::HYDRATE_SINGLE_SCALAR);
+
+ $filteredProducts = $this->productRepository->getProductsByTaxons(array($taxonId));
+
+ $filteredIds = array();
+ foreach ($filteredProducts as $filteredProduct) {
+ $filteredIds[] = $filteredProduct->getId();
+ }
+
+ return $filteredIds;
+ }
+
+ /**
+ * @param array $criteria
+ * @param array $orderBy
+ *
+ * @return mixed|\Pagerfanta\Pagerfanta
+ */
+ public function createPaginator(array $criteria = null, array $orderBy = null)
+ {
+ return $this->productRepository->createPaginator($criteria);
+ }
+
+ /**
+ * @param array $ids
+ *
+ * @return array
+ */
+ public function getProductsByIds(array $ids)
+ {
+ return $this->productRepository->findBy(array('id'=>$ids));
+ }
+
+ /**
+ * @return \Doctrine\ORM\QueryBuilder
+ */
+ public function getProductsQueryBuilder()
+ {
+ return $this->productRepository->getCollectionQueryBuilder();
+ }
+
+}
\ No newline at end of file
diff --git a/src/Sylius/Bundle/SearchBundle/Entity/SearchLog.php b/src/Sylius/Bundle/SearchBundle/Entity/SearchLog.php
new file mode 100644
index 0000000000..cefea86c53
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/Entity/SearchLog.php
@@ -0,0 +1,149 @@
+
+ */
+class SearchLog
+{
+ /**
+ * @var integer
+ */
+ private $id;
+
+ /**
+ * @var string
+ */
+ private $searchString;
+
+ /**
+ * @var string
+ */
+ private $client;
+
+ /**
+ * @var string
+ */
+ private $remoteAddress;
+
+ /**
+ * @var \DateTime
+ */
+ private $createdAt;
+
+
+ /**
+ * Get id.
+ *
+ * @return integer
+ */
+ public function getId()
+ {
+ return $this->id;
+ }
+
+ /**
+ * Set searchString
+ *
+ * @param string $searchString
+ * @return SearchLog
+ */
+ public function setSearchString($searchString)
+ {
+ $this->searchString = $searchString;
+
+ return $this;
+ }
+
+ /**
+ * Get searchString.
+ *
+ * @return string
+ */
+ public function getSearchString()
+ {
+ return $this->searchString;
+ }
+
+ /**
+ * Set client.
+ *
+ * @param string $client
+ * @return SearchLog
+ */
+ public function setClient($client)
+ {
+ $this->client = $client;
+
+ return $this;
+ }
+
+ /**
+ * Get client.
+ *
+ * @return string
+ */
+ public function getClient()
+ {
+ return $this->client;
+ }
+
+ /**
+ * @param $remoteAddress
+ *
+ * @return $this
+ */
+ public function setRemoteAddress($remoteAddress)
+ {
+ $this->remoteAddress = $remoteAddress;
+
+ return $this;
+ }
+
+ /**
+ * Get remoteAddress
+ *
+ * @return string
+ */
+ public function getRemoteAddress()
+ {
+ return $this->remoteAddress;
+ }
+
+ /**
+ * Set createdAt
+ *
+ * @param \DateTime $createdAt
+ * @return SearchLog
+ */
+ public function setCreatedAt($createdAt)
+ {
+ $this->createdAt = $createdAt;
+
+ return $this;
+ }
+
+ /**
+ * Get createdAt
+ *
+ * @return \DateTime
+ */
+ public function getCreatedAt()
+ {
+ return $this->createdAt;
+ }
+}
diff --git a/src/Sylius/Bundle/SearchBundle/Entity/SyliusSearchIndex.php b/src/Sylius/Bundle/SearchBundle/Entity/SyliusSearchIndex.php
new file mode 100644
index 0000000000..8ff0945456
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/Entity/SyliusSearchIndex.php
@@ -0,0 +1,177 @@
+
+ */
+class SyliusSearchIndex
+{
+ /**
+ * @var integer
+ */
+ private $id;
+
+ /**
+ * @var integer
+ */
+ private $itemId;
+
+ /**
+ * @var string
+ */
+ private $entity;
+
+ /**
+ * @var string
+ */
+ private $value;
+
+ /**
+ * @var string
+ */
+ private $tags;
+
+ /**
+ * @var \DateTime
+ */
+ private $createdAt;
+
+
+ /**
+ * Get id
+ *
+ * @return integer
+ */
+ public function getId()
+ {
+ return $this->id;
+ }
+
+ /**
+ * Set itemId
+ *
+ * @param integer $itemId
+ * @return SyliusSearchIndex
+ */
+ public function setItemId($itemId)
+ {
+ $this->itemId = $itemId;
+
+ return $this;
+ }
+
+ /**
+ * Get itemId
+ *
+ * @return integer
+ */
+ public function getItemId()
+ {
+ return $this->itemId;
+ }
+
+ /**
+ * Set entity
+ *
+ * @param string $entity
+ * @return SyliusSearchIndex
+ */
+ public function setEntity($entity)
+ {
+ $this->entity = $entity;
+
+ return $this;
+ }
+
+ /**
+ * Get entity
+ *
+ * @return string
+ */
+ public function getEntity()
+ {
+ return $this->entity;
+ }
+
+ /**
+ * Set value
+ *
+ * @param string $value
+ * @return SyliusSearchIndex
+ */
+ public function setValue($value)
+ {
+ $this->value = $value;
+
+ return $this;
+ }
+
+ /**
+ * Get value
+ *
+ * @return string
+ */
+ public function getValue()
+ {
+ return $this->value;
+ }
+
+ /**
+ * @param $tags
+ *
+ * @return $this
+ */
+ public function setTags($tags)
+ {
+ $this->tags = $tags;
+
+ return $this;
+ }
+
+ /**
+ * Get value
+ *
+ * @return string
+ */
+ public function getTags()
+ {
+ return $this->tags;
+ }
+
+ /**
+ * Set createdAt
+ *
+ * @param \DateTime $createdAt
+ * @return SyliusSearchIndex
+ */
+ public function setCreatedAt($createdAt)
+ {
+ $this->createdAt = $createdAt;
+
+ return $this;
+ }
+
+ /**
+ * Get createdAt
+ *
+ * @return \DateTime
+ */
+ public function getCreatedAt()
+ {
+ return $this->createdAt;
+ }
+}
diff --git a/src/Sylius/Bundle/SearchBundle/Finder/ElasticsearchFinder.php b/src/Sylius/Bundle/SearchBundle/Finder/ElasticsearchFinder.php
new file mode 100644
index 0000000000..4240910ddc
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/Finder/ElasticsearchFinder.php
@@ -0,0 +1,417 @@
+
+ */
+class ElasticsearchFinder implements FinderInterface
+{
+
+ /* @var */
+ private $searchRepository;
+
+ /* @var */
+ private $config;
+
+ /* @var */
+ private $productRepository;
+
+ /* @var */
+ private $container;
+
+ /* @var */
+ private $facets;
+
+ /* @var */
+ private $paginator;
+
+ /* @var */
+ private $facetGroup;
+
+ /* @var */
+ private $targetIndex;
+
+ /**
+ * @param $searchRepository
+ * @param $config
+ * @param $productRepository
+ * @param $container
+ */
+ public function __construct($searchRepository, $config, $productRepository, $container)
+ {
+ $this->searchRepository = $searchRepository;
+ $this->config = $config;
+ $this->productRepository = $productRepository;
+ $this->container = $container;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getPaginator()
+ {
+ return $this->paginator;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getFacets()
+ {
+ return $this->facets;
+ }
+
+ /**
+ * @param $targetIndex
+ *
+ * @return $this
+ */
+ public function setTargetIndex($targetIndex)
+ {
+ $this->targetIndex = $targetIndex;
+
+ return $this;
+ }
+
+ /**
+ * @param $facetGroup
+ *
+ * @return $this
+ */
+ public function setFacetGroup($facetGroup)
+ {
+ $this->facetGroup = $facetGroup;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * TODO: a simple if does the job for now, in future this should move to a
+ * chain of responsibility pattern or something similar.
+ */
+ public function find(Query $queryObject)
+ {
+ if ($queryObject instanceof SearchStringQuery) {
+ return $this->getResults($queryObject);
+
+ } elseif ($queryObject instanceof TaxonQuery) {
+ return $this->getResultsForTaxon($queryObject);
+
+ } else {
+ throw new Exception("finder can't handle this currently, feel free to implement it!");
+ }
+
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getResultsForTaxon(TaxonQuery $query)
+ {
+ if (isset($this->facetGroup)) {
+ $this->getConfiguredFilterSetsForFinders($this->facetGroup);
+ }
+
+ $finder = $this->container->get('fos_elastica.index.sylius.product');
+
+ $elasticaQuery = $this->compileElasticsearchQuery(null, $query->getAppliedFilters(), $this->config, $query->getTaxon()->getName());
+
+ $products = $finder->search($elasticaQuery);
+
+ $facets = null;
+ if (isset($this->facetGroup)) {
+ $facets = $this->transformFacetsForPresentation($products, $query->getAppliedFilters());
+ }
+
+ $paginator = $this->productRepository->createByTaxonPaginator($query->getTaxon(), array('id' => $this->getProductIdsFromFulltextSearch($products)));
+
+ $this->facets = $facets;
+ $this->paginator = $paginator;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getResults(SearchStringQuery $query)
+ {
+ if (isset($this->facetGroup)) {
+ $this->getConfiguredFilterSetsForFinders($this->facetGroup);
+ }
+
+ if (isset($this->targetIndex)) {
+ $finder = $this->container->get('fos_elastica.index.sylius.'.$this->targetIndex);
+ }else{
+ $finder = $this->container->get('fos_elastica.index.sylius');
+ }
+
+ $elasticaQuery = $this->compileElasticsearchQuery(
+ $query->getSearchTerm(),
+ $query->getAppliedFilters(),
+ $this->config,
+ $query->getSearchParam()
+ );
+
+ $products = $finder->search($elasticaQuery);
+
+ $facets = null;
+ if (isset($this->facetGroup)) {
+ $facets = $this->transformFacetsForPresentation($products, $query->getAppliedFilters());
+ }
+
+ $paginator = $this->searchRepository->createPaginator(array('id' => $this->getProductIdsFromFulltextSearch($products)));
+
+ $this->facets = $facets;
+ $this->paginator = $paginator;
+
+ return $this;
+ }
+
+ /**
+ * @param $searchTerm
+ * @param null $facets
+ * @param $configuration
+ * @param null $taxon
+ *
+ * @return \Elastica\Query
+ */
+ public function compileElasticsearchQuery($searchTerm, $facets = null, $configuration, $taxon = null)
+ {
+ $elasticaQuery = new \Elastica\Query();
+ $boolFilter = new \Elastica\Filter\Bool();
+
+ if (!$searchTerm) {
+ $query = new \Elastica\Query\Filtered();
+
+ $taxonFromRequestFilter = new \Elastica\Filter\Terms();
+ $taxonFromRequestFilter->setTerms('taxons', array($taxon));
+ $boolFilter->addMust($taxonFromRequestFilter);
+
+ $query->setFilter($boolFilter);
+
+ $elasticaQuery->setQuery($query);
+
+ } else {
+
+ if ($taxon != 'all') {
+ $query = new \Elastica\Query\Filtered();
+ $query->setQuery(new \Elastica\Query\QueryString($searchTerm));
+
+ $taxonFromRequestFilter = new \Elastica\Filter\Terms();
+ $taxonFromRequestFilter->setTerms('taxons', array($taxon));
+ $boolFilter->addMust($taxonFromRequestFilter);
+
+ $query->setFilter($boolFilter);
+ } else {
+ $query = new \Elastica\Query\QueryString($searchTerm);
+ }
+
+ $elasticaQuery->setQuery($query);
+ }
+
+ foreach ($configuration['filters']['facets'] as $name => $facet) {
+
+ // terms facet creation
+ if ($facet['type'] === 'terms') {
+ ${$name . 'AggregationFilter'} = new \Elastica\Aggregation\Filter($name);
+
+ ${$name . 'Aggregation'} = new \Elastica\Aggregation\Terms($name);
+ ${$name . 'Aggregation'}->setField($name);
+ ${$name . 'Aggregation'}->setSize(550);
+
+ ${$name . 'AggregationFilter'}->addAggregation(${$name . 'Aggregation'});
+ }
+
+ // range facet creation
+ if ($facet['type'] === 'range') {
+
+ ${$name . 'AggregationFilter'} = new \Elastica\Aggregation\Filter($name);
+
+ ${$name . 'Aggregation'} = new \Elastica\Aggregation\Range($name);
+ foreach($facet['values'] as $value) {
+ ${$name . 'Aggregation'}
+ ->setField($name)
+ ->addRange($value['from'], $value['to'])
+ ;
+ }
+
+ ${$name . 'AggregationFilter'}->addAggregation(${$name . 'Aggregation'});
+ }
+
+ if ($facet['type'] === 'attribute') {
+ ${$name . 'AggregationFilter'} = new \Elastica\Aggregation\Filter($name);
+
+ ${$name . 'Aggregation'} = new \Elastica\Aggregation\Terms($name);
+ ${$name . 'Aggregation'}->setField($name);
+
+ ${$name . 'AggregationFilter'}->addAggregation(${$name . 'Aggregation'});
+ }
+ }
+
+ if (!empty($facets)) {
+
+ $termFilters = new \Elastica\Filter\Term();
+ $rangeFilters = new \Elastica\Filter\Range();
+ $boolFilter = new \Elastica\Filter\Bool();
+
+ $filters = array();
+ foreach ($facets as $facet) {
+
+ if (strpos($facet[key($facet)], "|") !== false) {
+ $filters[key($facet)] = array('ranges' => explode('|', $facet[key($facet)]));
+ } else {
+ $filters[key($facet)] = array('term' => $facet[key($facet)]);
+ }
+ }
+
+ foreach ($filters as $name => $value) {
+
+ if (is_array($value[key($value)])) {
+ foreach ($value as $range) {
+ $rangeFilters->addField($name, array('gte' => $range[0], 'lte' => $range[1]));
+ $boolFilter->addMust($rangeFilters);
+ }
+ } else {
+ $termFilters->setTerm($name, $value[key($value)]);
+ $boolFilter->addMust($termFilters);
+ }
+ }
+
+
+ $elasticaQuery->setFilter($boolFilter);
+
+ foreach ($facets as $name => $facet) {
+
+ $normName = key($facet);
+
+ ${$normName . 'TermFilter'} = new \Elastica\Filter\Term();
+ ${$normName . 'RangeFilter'} = new \Elastica\Filter\Range();
+ ${$normName . 'BoolFilter'} = new \Elastica\Filter\Bool();
+
+ foreach ($filters as $value) {
+
+ if (is_array($value[key($value)])) {
+ foreach ($value as $range) {
+ ${$normName . 'RangeFilter'}->addField($name, array('gte' => $range[0], 'lte' => $range[1]));
+ ${$normName . 'BoolFilter'}->addMust($rangeFilters);
+ }
+ } else {
+ ${$normName . 'TermFilter'}->setTerm($name, $value[key($value)]);
+ ${$normName . 'BoolFilter'}->addMust($termFilters);
+ }
+ }
+
+ }
+
+ foreach ($this->config['filters']['facets'] as $name => $facet) {
+
+ foreach ($facets as $value) {
+
+ if (count($facets)>=count($this->config['filters']['facets'])) {
+ ${$name . 'AggregationFilter'}->setFilter($boolFilter);
+
+ } elseif ($name!=key($value)) {
+ if (isset(${key($value) . 'BoolFilter'})) {
+ ${$name . 'AggregationFilter'}->setFilter(${key($value) . 'BoolFilter'});
+ }
+ }
+ }
+ }
+
+ }
+
+ foreach ($configuration['filters']['facets'] as $name => $facet) {
+
+ if (!empty($facet)) {
+ $param = ${$name. 'AggregationFilter'}->hasParam('filter');
+
+ if ($param) {
+ $elasticaQuery->addAggregation(${$name. 'AggregationFilter'});
+ } else {
+ $elasticaQuery->addAggregation(${$name. 'Aggregation'});
+ }
+ } else {
+
+ $elasticaQuery->addAggregation(${$name. 'Aggregation'});
+ }
+ }
+
+ return $elasticaQuery;
+ }
+
+ /**
+ * @param $elements
+ *
+ * @return array
+ */
+ public function transformFacetsForPresentation($elements)
+ {
+ $tempFacets = $elements->getAggregations();
+
+ $elasticaFacets = array();
+
+ foreach($tempFacets as $name=>$facetData) {
+ unset($facetData['doc_count']);
+
+ if (isset($facetData[key($facetData)]['buckets'])) {
+ $elasticaFacets[key($facetData)] = $facetData[key($facetData)]['buckets'];
+ } else {
+ $elasticaFacets[$name] = $facetData['buckets'];
+ }
+ }
+
+ return array_reverse($elasticaFacets);
+ }
+
+ /**
+ * @param $products
+ *
+ * @return array|null
+ */
+ private function getProductIdsFromFulltextSearch($products)
+ {
+ $ids = array();
+ foreach ($products as $product) {
+ $ids[] = $product->getId();
+ }
+
+ if (empty($ids)) {
+ return null;
+ }
+
+ return $ids;
+ }
+
+ /**
+ * @param $filterSetName
+ */
+ private function getConfiguredFilterSetsForFinders($filterSetName)
+ {
+ foreach ($this->config['filters']['facets'] as $name => $value) {
+ if (!in_array($name, $this->config['filters']['facet_groups'][$filterSetName]['values'])) {
+ unset($this->config['filters']['facets'][$name]);
+ }
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/src/Sylius/Bundle/SearchBundle/Finder/FinderInterface.php b/src/Sylius/Bundle/SearchBundle/Finder/FinderInterface.php
new file mode 100644
index 0000000000..e6236096db
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/Finder/FinderInterface.php
@@ -0,0 +1,47 @@
+
+ */
+interface FinderInterface
+{
+
+ /**
+ * @param TaxonQuery $query
+ *
+ * @return mixed
+ */
+ public function getResultsForTaxon(TaxonQuery $query);
+
+ /**
+ * @param SearchStringQuery $query
+ *
+ * @return mixed
+ */
+ public function getResults(SearchStringQuery $query);
+
+ /**
+ * @param Query $queryObject
+ *
+ * @return mixed
+ */
+ public function find(Query $queryObject);
+
+}
\ No newline at end of file
diff --git a/src/Sylius/Bundle/SearchBundle/Finder/OrmFinder.php b/src/Sylius/Bundle/SearchBundle/Finder/OrmFinder.php
new file mode 100644
index 0000000000..449fa971f6
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/Finder/OrmFinder.php
@@ -0,0 +1,536 @@
+
+ */
+class OrmFinder implements FinderInterface
+{
+ /* @var */
+ private $searchRepository;
+
+ /* @var */
+ private $config;
+
+ /* @var */
+ private $productRepository;
+
+ /* @var */
+ private $container;
+
+ /* @var \Doctrine\ORM\EntityManager */
+ private $em;
+
+ /* @var */
+ private $facets;
+
+ /* @var */
+ private $paginator;
+
+ /* @var */
+ private $facetGroup;
+
+ /* @var */
+ private $targetIndex;
+
+ /**
+ * @param $searchRepository
+ * @param $config
+ * @param $productRepository
+ * @param $container
+ * @param EntityManager $em
+ */
+ public function __construct($searchRepository, $config, $productRepository, $container, EntityManager $em)
+ {
+ $this->searchRepository = $searchRepository;
+ $this->config = $config;
+ $this->productRepository = $productRepository;
+ $this->container = $container;
+ $this->em = $em;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getPaginator()
+ {
+ return $this->paginator;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getFacets()
+ {
+ return $this->facets;
+ }
+
+ /**
+ * @param $targetIndex
+ *
+ * @return $this
+ */
+ public function setTargetIndex($targetIndex)
+ {
+ $this->targetIndex = $targetIndex;
+
+ return $this;
+ }
+
+ /**
+ * @param $facetGroup
+ *
+ * @return $this
+ */
+ public function setFacetGroup($facetGroup)
+ {
+ $this->facetGroup = $facetGroup;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * TODO: a simple if does the job for now, in future this should move to a
+ * chain of responsibility pattern or something similar.
+ */
+ public function find(Query $queryObject)
+ {
+ if ($queryObject instanceof SearchStringQuery) {
+ return $this->getResults($queryObject);
+
+ } elseif ($queryObject instanceof TaxonQuery) {
+ return $this->getResultsForTaxon($queryObject);
+
+ } else {
+ throw new Exception("finder can't handle this currently, feel free to implement it!");
+ }
+
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getResultsForTaxon(TaxonQuery $query)
+ {
+ if (isset($this->facetGroup)) {
+ $this->getConfiguredFilterSetsForFinders($this->facetGroup);
+ }
+
+ $paginator = $this->productRepository->createByTaxonPaginator($query->getTaxon(), array());
+
+ // calculates the facets of the result set
+ $ids = array();
+ foreach ($paginator as $product) {
+ $ids[] = $product->getId();
+ }
+
+ $result = array();
+ $queryBuilder = $this->em->createQueryBuilder();
+ $queryBuilder
+ ->select('u.itemId, u.tags')
+ ->from('Sylius\Bundle\SearchBundle\Entity\SyliusSearchIndex', 'u')
+ ->where('u.itemId IN (:ids)')
+ ->setParameter('ids', $ids);
+
+ $res = $queryBuilder->getQuery()->getResult();
+ foreach ($res as $facet) {
+ $result[$facet['itemId']] = $facet['tags'];
+ }
+
+ $appliedFilters = $query->getAppliedFilters();
+ if (!$appliedFilters) {
+ $appliedFilters = array();
+ }
+
+ list($facetFilteredIds, $idsFromAllFacets) = $this->getFilteredIds($appliedFilters, $ids);
+
+ $facets = null;
+ if (isset($this->facetGroup)) {
+ $facets = $this->calculateNewFacets($result, $facetFilteredIds);
+ }
+
+ $paginator = $this->productRepository->createByTaxonPaginator($query->getTaxon(), array('id' => $idsFromAllFacets));
+
+ $this->facets = $facets;
+ $this->paginator = $paginator;
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function getResults(SearchStringQuery $query)
+ {
+ if (isset($this->facetGroup)) {
+ $this->getConfiguredFilterSetsForFinders($this->facetGroup);
+ }
+ // get ids and tags from full text search
+ $result = $this->query($query->getSearchTerm(), $this->em);
+
+ $ids = array_keys($result);
+
+ //filter the ids if searchParam is not all
+ if ($query->getSearchParam() != 'all' && $query->isDropdownFilterEnabled()) {
+ $ids = array_intersect(array_keys($result), $this->searchRepository->getProductIdsFromTaxonName($query->getSearchParam()));
+ }
+
+ $appliedFilters = $query->getAppliedFilters();
+ if (!$appliedFilters) {
+ $appliedFilters = array();
+ }
+
+ $idsFromAllFacets = null;
+ $facets = null;
+ if (!empty($ids)) {
+ list($facetFilteredIds, $idsFromAllFacets) = $this->getFilteredIds($appliedFilters, $ids);
+
+ if (isset($this->facetGroup)) {
+ $facets = $this->calculateNewFacets($result, $facetFilteredIds);
+ }
+ }
+
+ $paginator = $this->searchRepository->createPaginator(array('id' => $idsFromAllFacets));
+
+ $this->facets = $facets;
+ $this->paginator = $paginator;
+
+ return $this;
+ }
+
+ /**
+ * @param $filters
+ * @param $ids
+ *
+ * @return array
+ */
+ public function getFilteredIds($filters, $ids)
+ {
+ // Build up lists of product ids for each facet and the total intersect of all for full filtered set
+ $facetFilteredIds = array();
+ $idsFromAllFacets = $ids;
+
+ foreach ($this->config['filters']['facets'] as $facetName => $facetConfig) {
+ if ($thisFacetFilters = $this->getFiltersAppliedForFacet($facetName, $filters)) {
+ $facetFilteredIds[$facetName] = $this->getFilteredResults($ids, $thisFacetFilters);
+ } else {
+ $facetFilteredIds[$facetName] = $ids;
+ }
+
+ // Intersect this (possibly filtered) set of products for the facet with the main list
+ $idsFromAllFacets = array_intersect($idsFromAllFacets, $facetFilteredIds[$facetName]);
+ }
+
+ return array($facetFilteredIds, $idsFromAllFacets);
+
+ }
+
+ /**
+ * Returns an array with the new calculated facets
+ *
+ * @param $result
+ * @param $facetFilteredIds
+ *
+ * @return array
+ */
+ public function calculateNewFacets($result, $facetFilteredIds)
+ {
+ $ids = array_keys($result);
+ // Fetch the 'options' for each facet based on the limited set of products from the other facets
+ $facets = array();
+
+ foreach ($this->config['filters']['facets'] as $facetName => $ormFacet) {
+
+ $idsFromOtherFacets = $ids;
+ // Loop around other facets to get the intersect of all of their possibly filtered sets
+ foreach ($facetFilteredIds as $otherFacetName => $otherFacetIds) {
+ if ($otherFacetName != $facetName) {
+ $idsFromOtherFacets = array_intersect($idsFromOtherFacets, $otherFacetIds);
+ }
+ }
+
+ $facets[$facetName] = $this->getFacet($idsFromOtherFacets, $this->config['filters']['facets'], $facetName, $result);
+ }
+
+ return $facets;
+ }
+
+ /**
+ * Builds an individual facet category
+ *
+ * @param $idsFromOtherFacets
+ * @param $facets
+ * @param $givenFacetName
+ * @param $result
+ *
+ * @return mixed
+ */
+ public function getFacet($idsFromOtherFacets, $facets, $givenFacetName, $result)
+ {
+ // gathers the appearance of the elements
+ $rawFacets = $this->calculateRawFacets($idsFromOtherFacets, $result);
+
+ // formats the data for sending out the presentation array
+ $facetConfig = $facets[$givenFacetName];
+ $finalFacets[$givenFacetName] = array();
+
+ if (isset($rawFacets[$givenFacetName])) {
+
+ foreach ($rawFacets[$givenFacetName] as $facet => $count) {
+
+ if (is_numeric($facet) && $facetConfig['type'] == 'range') {
+
+ foreach ($facetConfig['values'] as $key => $range) {
+ if ($facet >= $range['from'] && $facet <= $range['to']) {
+ if (empty ($finalFacets[$givenFacetName][$key])) {
+ $finalFacets[$givenFacetName][$key] = array('from' => $range['from'], 'to' => $range['to'], 'doc_count' => 1);
+ } else {
+ $finalFacets[$givenFacetName][$key]['doc_count'] += 1;
+ }
+ }
+
+ }
+
+ asort($finalFacets[$givenFacetName]);
+ } else {
+ $finalFacets[$givenFacetName][] = array('key' => $facet, 'doc_count' => $count);
+ }
+ }
+ }
+
+ return $finalFacets[$givenFacetName];
+ }
+
+ /**
+ * @param $facetName
+ * @param $filters
+ *
+ * @return array
+ */
+ public function getFiltersAppliedForFacet($facetName, $filters)
+ {
+ $filtersForFacet = array();
+
+ foreach ($filters as $filter) {
+
+ $filterName = key($filter);
+
+ if ($facetName == preg_replace('/\d/', '', $filterName)) {
+ $filtersForFacet[] = array($filterName => $filter[key($filter)]);
+ }
+ }
+
+ return $filtersForFacet;
+ }
+
+ /**
+ * Reduce the result set based on facets
+ *
+ * @param array $ids
+ * @param array $filters
+ *
+ * @internal param array $facets
+ *
+ * @return array
+ */
+ private function getFilteredResults(array $ids, array $filters)
+ {
+ $filteredIds = array_intersect(
+ $this->getFilteredResultsForRange($ids, $filters),
+ $this->getFilteredResultsForTerms($ids, $filters)
+ );
+
+ return $filteredIds;
+ }
+
+
+ /**
+ * @param array $ids
+ * @param array $filters
+ *
+ * @return array
+ */
+ public function getFilteredResultsForRange(array $ids, array $filters)
+ {
+ foreach ($filters as $key => $filter) {
+ if (strpos($filter[key($filter)], "|") === false) {
+ unset($filters[$key]);
+ }
+ }
+
+ if (empty($filters)) {
+ return $ids;
+ }
+
+ $queryBuilder = $this->searchRepository->getProductsQueryBuilder();
+ $queryBuilder
+ ->leftJoin('product.taxons', 'taxon')
+ ->leftJoin('product.attributes', 'attribute')
+ ->leftJoin('product.variants', 'variant')
+ ->where('product.id IN (:ids)')
+ ->setParameter('ids', $ids);
+
+ $orx = $queryBuilder->expr()->orX();
+
+
+ foreach ($filters as $separateFilter) {
+
+ $filter = $separateFilter[key($separateFilter)];
+
+ if (strpos($filter, "|")) {
+ $range = explode("|", $filter);
+
+ $orx->add('variant.price>=' . $range[0] . ' AND variant.price<=' . $range[1] . ' AND variant.master=1');
+ } elseif (strpos($filter, "taxon") !== false) {
+ $orx->add('taxon.name = \'' . $filter . '\'');
+ } elseif (strpos($filter, "_") !== false) {
+ $orx->add('attribute.value = \'' . $filter . '\'');
+ }
+ }
+
+ $queryBuilder->andWhere($orx);
+
+ $results = $queryBuilder->getQuery()->getResult();
+
+ $ids = array();
+ foreach ($results as $result) {
+ $ids[] = $result->getId();
+ }
+
+ return $ids;
+ }
+
+ function getFilteredResultsForTerms(array $ids, array $filters)
+ {
+ foreach ($filters as $key => $filter) {
+ if (strpos($filter[key($filter)], "|")) {
+ unset($filters[$key]);
+ }
+ }
+
+ if (empty($filters)) {
+ return $ids;
+ }
+
+ $result = array();
+ $queryBuilder = $this->em->createQueryBuilder();
+ $queryBuilder
+ ->select('u.itemId, u.tags')
+ ->from('Sylius\Bundle\SearchBundle\Entity\SyliusSearchIndex', 'u')
+ ->where('u.id IN (:ids)')
+ ->setParameter('ids', $ids);
+
+ $res = $queryBuilder->getQuery()->getResult();
+ foreach ($res as $facet) {
+
+ foreach ($filters as $separateFilter) {
+ $tags = unserialize($facet['tags']);
+
+ if (in_array(ucfirst($separateFilter[key($separateFilter)]), $tags[strtolower(key($separateFilter))])) {
+ $result[$facet['itemId']] = $facet['tags'];
+ }
+ }
+ }
+
+ return array_keys($result);
+ }
+
+ /**
+ * @param $searchTerm
+ * @param EntityManager $em
+ *
+ * @return array
+ */
+ public function query($searchTerm, EntityManager $em)
+ {
+ $sql = 'select item_id, tags, entity from sylius_search_index WHERE MATCH(value) AGAINST (:searchterm)';
+
+ $stmt = $em->getConnection()->prepare($sql);
+ $stmt->execute(array('searchterm' => strip_tags($searchTerm)));
+ $results = $stmt->fetchAll();
+
+ $facets = array();
+ foreach ($results as $result) {
+
+ if (isset($this->targetIndex) && $result['entity'] != $this->config['orm_indexes'][$this->targetIndex]['class']) {
+ continue;
+ }
+
+ $facets[$result['item_id']] = $result['tags'];
+ }
+
+ return $facets;
+ }
+
+ /**
+ * @param $filterSetName
+ */
+ private function getConfiguredFilterSetsForFinders($filterSetName)
+ {
+ foreach ($this->config['filters']['facets'] as $name => $value) {
+ if (!in_array($name, $this->config['filters']['facet_groups'][$filterSetName]['values'])) {
+ unset($this->config['filters']['facets'][$name]);
+ }
+ }
+
+ }
+
+ /**
+ * @param $idsFromOtherFacets
+ * @param $result
+ *
+ * @return array
+ */
+ public function calculateRawFacets($idsFromOtherFacets, $result)
+ {
+ $rawFacets = array();
+
+ foreach ($result as $id => $tags) {
+ if (!in_array($id, $idsFromOtherFacets)) {
+ unset($result[$id]);
+ }
+ }
+
+ foreach ($result as $serializedTags) {
+ $tags = unserialize($serializedTags);
+ foreach ($tags as $name => $value) {
+
+ if (is_array($value)) {
+ foreach ($value as $v) {
+ $rawFacets[$name][] = $v;
+ }
+ } elseif (is_numeric($value)) {
+ $rawFacets[$name][] = intval($value);
+ }
+ }
+ }
+
+ foreach ($rawFacets as $name => $facet) {
+ $rawFacets[$name] = array_count_values($facet);
+ }
+
+ return $rawFacets;
+ }
+
+
+}
\ No newline at end of file
diff --git a/src/Sylius/Bundle/SearchBundle/Indexer/ElasticsearchIndexer.php b/src/Sylius/Bundle/SearchBundle/Indexer/ElasticsearchIndexer.php
new file mode 100644
index 0000000000..e935c21c4d
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/Indexer/ElasticsearchIndexer.php
@@ -0,0 +1,56 @@
+
+ */
+class ElasticSearchIndexer implements IndexerInterface
+{
+ /* @var \Sylius\Bundle\CoreBundle\Kernel\Kernel */
+ private $kernel;
+
+ /**
+ * @param Kernel $kernel
+ */
+ public function __construct(Kernel $kernel)
+ {
+ $this->kernel = $kernel;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function populate(EntityManager $em = null)
+ {
+ $environment = $this->kernel->getEnvironment();
+
+ $populateCommand = sprintf("/console fos:elastica:populate --env=%s", $environment);
+
+ $command = $this->kernel->getRootDir() . $populateCommand;
+ $process = new Process($command);
+ $process->run();
+
+ if (!$process->isSuccessful()) {
+ throw new \RuntimeException($process->getErrorOutput());
+ }
+
+ print $process->getOutput();
+ }
+
+}
\ No newline at end of file
diff --git a/src/Sylius/Bundle/SearchBundle/Indexer/IndexerInterface.php b/src/Sylius/Bundle/SearchBundle/Indexer/IndexerInterface.php
new file mode 100644
index 0000000000..212d147293
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/Indexer/IndexerInterface.php
@@ -0,0 +1,37 @@
+
+ */
+interface IndexerInterface
+{
+
+ /**
+ * Populates the index table.
+ *
+ * Entity manager is needed by orm indexer but not for es indexer. It exists
+ * here to overcome a circular reference error on the orm side. There was a solution
+ * by using events but ends up creating more complicated code than it suppose to be.
+ *
+ * @param EntityManager $em
+ *
+ * @return mixed
+ */
+ public function populate(EntityManager $em);
+
+}
\ No newline at end of file
diff --git a/src/Sylius/Bundle/SearchBundle/Indexer/OrmIndexer.php b/src/Sylius/Bundle/SearchBundle/Indexer/OrmIndexer.php
new file mode 100644
index 0000000000..3a13b92024
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/Indexer/OrmIndexer.php
@@ -0,0 +1,266 @@
+
+ */
+class OrmIndexer implements IndexerInterface
+{
+ /**
+ * @var array
+ */
+ private $config;
+
+ /**
+ * @var
+ */
+ private $em;
+
+ /**
+ * @var
+ */
+ protected $transformer;
+
+ const SPACER = ' ';
+
+ public function __construct(Array $config, ModelToElasticaAutoTransformer $transformer)
+ {
+ $this->config = $config;
+ $this->transformer = $transformer;
+ }
+
+ /**
+ * @param EntityManager $em
+ */
+ public function setEntityManager(EntityManager $em)
+ {
+ $this->em = $em;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function populate(EntityManager $em = null)
+ {
+ $this->setEntityManager($em);
+
+ echo 'Reseting index' . PHP_EOL;
+
+ $sql = 'show index from sylius_search_index';
+ $stmt = $this->em->getConnection()
+ ->prepare($sql);
+ $stmt->execute();
+
+ $res = $stmt->fetchAll(\PDO::FETCH_COLUMN, 2);
+
+ if (in_array('fulltext_search_idx', array_values($res))) {
+ $sql = 'alter table sylius_search_index drop key fulltext_search_idx';
+ $stmt = $this->em->getConnection()
+ ->prepare($sql);
+ $stmt->execute();
+ }
+
+ $sql = 'truncate sylius_search_index';
+ $stmt = $this->em->getConnection()
+ ->prepare($sql);
+ $stmt->execute();
+
+ foreach ($this->config['orm_indexes'] as $index) {
+ $this->createIndex($index['class'], $index['mappings']);
+ }
+
+ $sql = 'create fulltext index fulltext_search_idx on sylius_search_index (value)';
+ $stmt = $this->em->getConnection()
+ ->prepare($sql);
+ $stmt->execute();
+ }
+
+ /**
+ * @param $entity
+ * @param $fields
+ *
+ * @internal param $table
+ */
+ private function createIndex($entity, $fields)
+ {
+
+ $a = array_keys($fields);
+ foreach ($a as &$value) {
+ $value = 'u.' . $value;
+ }
+
+ echo 'Populating index table with ' . $entity . ' data' . PHP_EOL;
+
+ $queryBuilder = $this->em->createQueryBuilder();
+ $queryBuilder
+ ->select('u')
+ ->from($entity, 'u')
+ ->where('u.deletedAt IS NULL');
+ $results = $queryBuilder->getQuery()->getResult();
+
+ foreach ($results as $element) {
+ $this->createIndexForEntity($entity, $fields, $element);
+ }
+
+ echo 'Index created successfully' . PHP_EOL;
+
+ }
+
+ /**
+ * @param array $entities
+ */
+ public function insertMany(array $entities)
+ {
+ foreach($entities as $entity) {
+ $class = get_class($entity);
+
+ $indexName = $this->nestedValues($this->config['orm_indexes'], $class);
+
+ $this->createIndexForEntity($class, $this->config['orm_indexes'][$indexName]['mappings'], $entity);
+ }
+ }
+
+ /**
+ * @param array $entities
+ */
+ public function removeMany(array $entities)
+ {
+ foreach($entities as $entity) {
+ $this->removeIndexForEntity($entity);
+ }
+ }
+
+ /**
+ * @param $entityName
+ * @param $fields
+ * @param $entity
+ */
+ public function createIndexForEntity($entityName, $fields, $entity)
+ {
+ $content = $this->compileSearchableContent($fields, $entity);
+
+ $queryBuilder = $this->em->createQueryBuilder();
+ $queryBuilder
+ ->select('u')
+ ->from('Sylius\Bundle\SearchBundle\Entity\SyliusSearchIndex', 'u')
+ ->where('u.itemId = :item_id')
+ ->andWhere('u.entity = :entity_namespace')
+ ->setParameter(':item_id', $entity->getId())
+ ->setParameter(':entity_namespace', get_class($entity));
+
+ try {
+
+ $syliusSearchIndex = $queryBuilder->getQuery()->getSingleResult();
+ $syliusSearchIndex->setValue($content);
+
+ }catch(\Doctrine\ORM\NoResultException $e) {
+
+ $syliusSearchIndex = new SyliusSearchIndex();
+
+ $syliusSearchIndex->setItemId($entity->getId());
+ $syliusSearchIndex->setEntity($entityName);
+ $syliusSearchIndex->setValue($content);
+ }
+
+ $this->getTagsForElementAndSave($entity, $syliusSearchIndex);
+ }
+
+ /**
+ * @param $entity
+ */
+ public function removeIndexForEntity($entity)
+ {
+ $queryBuilder = $this->em->createQueryBuilder();
+ $queryBuilder
+ ->delete('Sylius\Bundle\SearchBundle\Entity\SyliusSearchIndex', 'u')
+ ->where('u.itemId = :item_id')
+ ->andWhere('u.entity = :entity_namespace')
+ ->setParameter(':item_id', $entity->getId())
+ ->setParameter(':entity_namespace', get_class($entity));
+
+ $result = $queryBuilder->getQuery()->getResult();
+ }
+
+ /**
+ * @param $element
+ * @param $syliusSearchIndex
+ */
+ public function getTagsForElementAndSave($element, $syliusSearchIndex)
+ {
+ /*
+ * We bound orm with elasticsearch at this point. I could separate the logic but this
+ * means that we will have logic duplication. Maybe this could be refactored in the future.
+ */
+ $elasticaDocument = $this->transformer->transform($element, array_flip(array_keys($this->config['filters']['facets'])));
+ $syliusSearchIndex->setTags(serialize($elasticaDocument->getData()));
+
+ $this->em->persist($syliusSearchIndex);
+ $this->em->flush();
+ }
+
+ /**
+ * @param $fields
+ * @param $element
+ *
+ * @return string
+ */
+ public function compileSearchableContent($fields, $element)
+ {
+ $content = '';
+ foreach (array_keys(array_slice($fields, 1)) as $field) {
+ $func = 'get' . ucfirst($field);
+ $content .= $element->$func() . self::SPACER;
+ }
+
+ return $content;
+ }
+
+ /**
+ * Checks if the object is indexable or not.
+ *
+ * @param object $object
+ * @return bool
+ */
+ public function isObjectIndexable($object)
+ {
+ foreach ($this->config['orm_indexes'] as $index) {
+ if ($index['class'] == get_class($object) && $this->config['driver'] == 'orm') {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ /**
+ * @param $node
+ *
+ * @return string
+ */
+ public function nestedValues($node) {
+ if (is_array($node)) {
+ $ret = '';
+ foreach($node as $key => $val)
+ $ret = $this->nestedValues($key, $val);
+ return $ret;
+ }
+ return $node;
+ }
+
+}
\ No newline at end of file
diff --git a/src/Sylius/Bundle/SearchBundle/Listener/OrmListener.php b/src/Sylius/Bundle/SearchBundle/Listener/OrmListener.php
new file mode 100644
index 0000000000..29713a708c
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/Listener/OrmListener.php
@@ -0,0 +1,130 @@
+
+ */
+class OrmListener implements EventSubscriber
+{
+
+ /**
+ * @var
+ */
+ private $ormIndexer;
+
+ /**
+ * Objects scheduled for insertion and replacement
+ */
+ public $scheduledForInsertion = array();
+ public $scheduledForDeletion = array();
+
+ /**
+ * @param OrmIndexer $ormIndexer
+ */
+ public function __construct(OrmIndexer $ormIndexer)
+ {
+ $this->ormIndexer = $ormIndexer;
+ }
+
+ /**
+ * @return array
+ */
+ public function getSubscribedEvents()
+ {
+ return array(
+ 'postFlush',
+ 'postPersist',
+ 'postUpdate',
+ 'preRemove',
+ );
+ }
+
+ /**
+ * Happens after updating a product
+ *
+ * @param LifecycleEventArgs $args
+ */
+ public function postUpdate(LifecycleEventArgs $args)
+ {
+ $entity = $args->getEntity();
+
+ if ($this->ormIndexer->isObjectIndexable($entity)) {
+ $this->scheduledForInsertion[] = $entity;
+ }
+ }
+
+ public function postPersist(LifecycleEventArgs $args)
+ {
+ $entity = $args->getEntity();
+
+ if ($this->ormIndexer->isObjectIndexable($entity)) {
+ $this->scheduledForInsertion[] = $entity;
+ }
+ }
+
+ /**
+ * @param PostFlushEventArgs $args
+ */
+ public function postFlush(PostFlushEventArgs $args)
+ {
+ $this->index($args);
+ }
+
+ /**
+ * Happens before the deletion of a product
+ *
+ * @param LifecycleEventArgs $args
+ */
+ public function preRemove(LifecycleEventArgs $args)
+ {
+ $entity = $args->getEntity();
+
+ if ($this->ormIndexer->isObjectIndexable($entity)) {
+ $this->scheduledForDeletion[] = $entity;
+ }
+
+ }
+
+ /**
+ * @param $args
+ */
+ public function index($args)
+ {
+ // workaround to avoid circular reference of entity manager on indexer service definition
+ $this->ormIndexer->setEntityManager($args->getEntityManager());
+
+ if (count($this->scheduledForInsertion)) {
+
+ // trick to clear the array and avoid looping
+ $scheduledForInsertion = $this->scheduledForInsertion;
+ $this->scheduledForInsertion = array();
+
+ $this->ormIndexer->insertMany($scheduledForInsertion);
+ }
+
+ if (count($this->scheduledForDeletion)) {
+
+ //this uses dql
+ $this->ormIndexer->removeMany($this->scheduledForDeletion);
+ }
+
+ }
+
+}
\ No newline at end of file
diff --git a/src/Sylius/Bundle/SearchBundle/Query/Query.php b/src/Sylius/Bundle/SearchBundle/Query/Query.php
new file mode 100644
index 0000000000..c0ac985531
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/Query/Query.php
@@ -0,0 +1,39 @@
+
+ */
+class Query
+{
+
+ /* @var */
+ private $appliedFilters;
+
+ /**
+ * @param $appliedFilters
+ */
+ public function setAppliedFilters($appliedFilters)
+ {
+ $this->appliedFilters = $appliedFilters;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getAppliedFilters()
+ {
+ return $this->appliedFilters;
+ }
+
+}
\ No newline at end of file
diff --git a/src/Sylius/Bundle/SearchBundle/Query/SearchStringQuery.php b/src/Sylius/Bundle/SearchBundle/Query/SearchStringQuery.php
new file mode 100644
index 0000000000..c499a01540
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/Query/SearchStringQuery.php
@@ -0,0 +1,84 @@
+
+ */
+class SearchStringQuery extends Query
+{
+
+ /* @var */
+ private $searchTerm;
+
+ /* @var */
+ private $searchParam;
+
+ /* @var */
+ private $dropdownFilterEnabled;
+
+ /**
+ * @param $searchTerm
+ * @param $searchParam
+ * @param $appliedFilters
+ * @param $dropdownFilterEnabled
+ */
+ public function __construct($searchTerm, $searchParam, $appliedFilters, $dropdownFilterEnabled)
+ {
+ parent::setAppliedFilters($appliedFilters);
+ $this->searchTerm = $searchTerm;
+ $this->searchParam = $searchParam;
+ $this->dropdownFilterEnabled = $dropdownFilterEnabled;
+ }
+
+ /**
+ * @param $searchTerm
+ */
+ public function setSearchTerm($searchTerm)
+ {
+ $this->searchTerm = $searchTerm;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getSearchTerm()
+ {
+ return $this->searchTerm;
+ }
+
+ /**
+ * @param $searchParam
+ */
+ public function setSearchParam($searchParam)
+ {
+ $this->searchParam = $searchParam;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getSearchParam()
+ {
+ return $this->searchParam;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function isDropdownFilterEnabled()
+ {
+ return $this->dropdownFilterEnabled;
+ }
+
+}
diff --git a/src/Sylius/Bundle/SearchBundle/Query/TaxonQuery.php b/src/Sylius/Bundle/SearchBundle/Query/TaxonQuery.php
new file mode 100644
index 0000000000..950563e825
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/Query/TaxonQuery.php
@@ -0,0 +1,50 @@
+
+ */
+class TaxonQuery extends Query
+{
+
+ /* @var */
+ private $taxon;
+
+ /**
+ * @param Taxon $taxon
+ * @param $appliedFilters
+ */
+ public function __construct(Taxon $taxon, $appliedFilters)
+ {
+ parent::setAppliedFilters($appliedFilters);
+ $this->taxon = $taxon;
+ }
+
+ /**
+ * @param Taxon $taxon
+ */
+ public function setTaxon(Taxon $taxon)
+ {
+ $this->taxon = $taxon;
+ }
+
+ /**
+ * @return Taxon
+ */
+ public function getTaxon()
+ {
+ return $this->taxon;
+ }
+
+}
\ No newline at end of file
diff --git a/src/Sylius/Bundle/SearchBundle/README.md b/src/Sylius/Bundle/SearchBundle/README.md
new file mode 100644
index 0000000000..1c3d1b7d68
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/README.md
@@ -0,0 +1,247 @@
+SyliusSearchBundle [](http://travis-ci.org/Sylius/SyliusSearchBundle)
+===================
+
+Search system for [**Symfony2**](http://symfony.com) applications.
+
+It supports search functionality for mysql and elastic search.
+
+Usage:
+
+Place the following snippets on a twig view and you are ready to go.
+
+```php
+{% render controller('SyliusSearchBundle:Search:form', {'request':app.request}) %}
+
+{% include 'SyliusSearchBundle::filter_form.html.twig' %}
+```
+
+If you want to use the search pragmatically there are currently 2 query types, string query and taxon query.
+
+```php
+$finder = $this->get('sylius_search.finder')
+ ->setTargetIndex('product') // target index searches in a specific type, if it's not set it searches in all types
+ ->setFacetGroup('search_set') // configuration based, uses the relevant facet set, if not set it does not show facets
+ ->find(new SearchStringQuery(...));
+```
+
+Taxon
+```php
+$finder = $this->get('sylius_search.finder')
+ ->setFacetGroup('categories_set')
+ ->find(new TaxonQuery(...));
+```
+
+Basic configuration guidelines:
+
+### Form
+```yaml
+form: 'SyliusSearchBundle::form.html.twig'
+```
+
+The actual form you want to use for performing a search. As long as the naming of the elements is the same you can define a new twig file with your own design.
+
+### Items per page
+
+```yaml
+items_per_page: 9
+```
+
+Pagination items for search results
+
+### Driver
+
+```yaml
+driver: orm
+```
+
+Possible values: orm, elasticsearch
+
+If orm is selected the search uses mysql as engine, if elasticsearch is selected if uses the elasticsearch search engine, which must be configured through the fos_elastica bundle. For documentation on fos_elastica please check the [fos_elastica github page](https://github.com/FriendsOfSymfony/FOSElasticaBundle)
+
+### Indexes
+```yaml
+orm_indexes: # it is being used only when orm is selected as a driver
+ product: # indentifier of an index
+ class: Sylius\Component\Core\Model\Product # the corresponding model
+ mappings: # appart from the id, the rest of the fields will be used to compile the searchable content
+ id: ~
+ name: ~
+ description: ~
+```
+
+if elasticsearch is selected here is a sample configuration
+
+```yaml
+fos_elastica:
+ clients:
+ elasticsearch:
+ servers:
+ - { host: 10.0.0.100, port: 9200, logger: true }
+ - { host: 10.0.0.101, port: 9200, logger: true }
+ #for clustering you need to define the logger because of the https://github.com/FriendsOfSymfony/FOSElasticaBundle/issues/543
+ indexes:
+ sylius:
+ client: elasticsearch
+ finder: ~
+ settings:
+ analysis:
+ filter:
+ synonym:
+ type: synonym
+ synonyms: synonym.txt
+ analyzer:
+ my_analyzer:
+ filter: synonym
+ type: standard
+ tokenizer: standard
+ types:
+ product:
+ mappings:
+ name:
+ type: string
+ analyzer: my_analyzer
+ description: ~
+ slug: ~
+ color: ~
+ price:
+ type: integer
+ taxons:
+ type: string
+ index: not_analyzed
+ size: ~
+ author: ~
+ made_of:
+ type: string
+ index: not_analyzed
+
+ persistence:
+ driver: orm
+ model: Sylius\Component\Core\Model\Product
+ model_to_elastica_transformer:
+ service: sylius.search.transformers.model.product
+ provider: ~
+ listener: ~
+ finder: ~
+```
+
+### Filters
+
+```yaml
+filters:
+ search_filter: # the small drop down menu on the side of the search field
+ enabled: true
+ taxonomy: category # possible values are the high level taxons (category, brand for sylius)
+ facet_groups: # possible facet groups, you assign them in a finder object
+ search_set:
+ values: [taxons, price, made_of, color]
+ categories_set:
+ values: [price, made_of, color]
+ all_set:
+ values: [taxons, price, made_of]
+ facets: # possible facets, as long as a model exposes attributes, options or getters with the given name, it will be used as a facet
+ taxons:
+ display_name: 'Basic categories'
+ type: terms
+ value: ~
+ price:
+ display_name: 'Available prices'
+ type: range
+ values:
+ - { from: 0, to: 2000}
+ - { from: 2001, to: 5000}
+ - { from: 5001, to: 10000}
+ made_of:
+ display_name: 'Material'
+ type: terms
+ value: ~
+ color:
+ display_name: 'Available colors'
+ type: terms
+ value: ~
+```
+
+### Indexing data
+
+After configuring the indexer properly you execute the following command to populate the appropriate engine
+
+```bash
+app/console sylius:search:index
+```
+
+Sylius
+------
+
+**Sylius** - Modern ecommerce for Symfony2. Visit [Sylius.org](http://sylius.org).
+
+[phpspec](http://phpspec.net) examples
+--------------------------------------
+
+```bash
+$ composer install
+$ bin/phpspec run -fpretty
+```
+
+[behat](http://behat.org) examples for search bundle
+--------------------------------------
+
+```bash
+$ composer install
+$ /bin/behat --suite=search
+```
+
+Documentation
+-------------
+
+Documentation is available on [**docs.sylius.org**](http://docs.sylius.org/en/latest/bundles/SyliusProductBundle/index.html).
+
+Contributing
+------------
+
+All informations about contributing to Sylius can be found on [this page](http://docs.sylius.org/en/latest/contributing/index.html).
+
+Mailing lists
+-------------
+
+### Users
+
+Questions? Feel free to ask on [users mailing list](http://groups.google.com/group/sylius).
+
+### Developers
+
+To contribute and develop this bundle, use the [developers mailing list](http://groups.google.com/group/sylius-dev).
+
+Sylius twitter account
+----------------------
+
+If you want to keep up with updates, [follow the official Sylius account on twitter](http://twitter.com/Sylius).
+
+Bug tracking
+------------
+
+This bundle uses [GitHub issues](https://github.com/Sylius/Sylius/issues).
+If you have found bug, please create an issue.
+
+Versioning
+----------
+
+Releases will be numbered with the format `major.minor.patch`.
+
+And constructed with the following guidelines.
+
+* Breaking backwards compatibility bumps the major.
+* New additions without breaking backwards compatibility bumps the minor.
+* Bug fixes and misc changes bump the patch.
+
+For more information on SemVer, please visit [semver.org website](http://semver.org/).
+This versioning method is same for all **Sylius** bundles and applications.
+
+MIT License
+-----------
+
+License can be found [here](https://github.com/Sylius/SyliusProductBundle/blob/master/Resources/meta/LICENSE).
+
+Authors
+-------
+
+The bundle was originally created by [Argyrios Gounaris](http://agounaris.github.io).
+See the list of [contributors](https://github.com/Sylius/SyliusProductBundle/contributors).
diff --git a/src/Sylius/Bundle/SearchBundle/Resources/config/Doctrine/SearchLog.orm.xml b/src/Sylius/Bundle/SearchBundle/Resources/config/Doctrine/SearchLog.orm.xml
new file mode 100644
index 0000000000..36ed9bd80f
--- /dev/null
+++ b/src/Sylius/Bundle/SearchBundle/Resources/config/Doctrine/SearchLog.orm.xml
@@ -0,0 +1,37 @@
+
+
+
+
+