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 [![Build status...](https://secure.travis-ci.org/Sylius/SyliusSearchBundle.png?branch=master)](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 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Sylius/Bundle/SearchBundle/Resources/config/Doctrine/SyliusSearchIndex.orm.xml b/src/Sylius/Bundle/SearchBundle/Resources/config/Doctrine/SyliusSearchIndex.orm.xml new file mode 100644 index 0000000000..4fa7fdcff4 --- /dev/null +++ b/src/Sylius/Bundle/SearchBundle/Resources/config/Doctrine/SyliusSearchIndex.orm.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Sylius/Bundle/SearchBundle/Resources/config/services.xml b/src/Sylius/Bundle/SearchBundle/Resources/config/services.xml new file mode 100644 index 0000000000..4319206517 --- /dev/null +++ b/src/Sylius/Bundle/SearchBundle/Resources/config/services.xml @@ -0,0 +1,86 @@ + + + + + + + + Sylius\Bundle\SearchBundle\Doctrine\ORM\SyliusSearchIndexRepository + FOS\ElasticaBundle\Command\PopulateCommand + Sylius\Bundle\SearchBundle\Finder\OrmFinder + Sylius\Bundle\SearchBundle\Finder\ElasticsearchFinder + %sylius_search.finder% + Sylius\Bundle\SearchBundle\Finder\ProductTaxonFinder + Sylius\Bundle\SearchBundle\Finder\ProductTextFinder + Sylius\Bundle\SearchBundle\Finder\EverythingTextFinder + FOS\ElasticaBundle\Transformer\ModelToElasticaAutoTransformer + Sylius\Bundle\SearchBundle\Indexer\OrmIndexer + Sylius\Bundle\SearchBundle\Indexer\ElasticsearchIndexer + + + + + + + + + + + %sylius_search.config% + + + + + + + + + + + + + %sylius_search.config% + + + + + + + + + + + + + + + + + + + + + + + %sylius_search.config% + + + + + + + + + diff --git a/src/Sylius/Bundle/SearchBundle/Resources/views/filter_form.html.twig b/src/Sylius/Bundle/SearchBundle/Resources/views/filter_form.html.twig new file mode 100644 index 0000000000..290874283b --- /dev/null +++ b/src/Sylius/Bundle/SearchBundle/Resources/views/filter_form.html.twig @@ -0,0 +1,58 @@ +{#This form has no form type. I could use a form type but there is no data binding with a model#} +{#and also I don't currently need any validation.#} + +{#For a future release I could move it to a form type if there is a good reason for it.#} + +{% if facets is defined and facets is not null %} + +
+ {% for name, facet in facets %} + {% if facet is not empty %} + {{ facetTags[name]['display_name'] }} + + {% endif %} + {% endfor %} + + + +
+
+{% endif %} \ No newline at end of file diff --git a/src/Sylius/Bundle/SearchBundle/Resources/views/form.html.twig b/src/Sylius/Bundle/SearchBundle/Resources/views/form.html.twig new file mode 100644 index 0000000000..8132c289a3 --- /dev/null +++ b/src/Sylius/Bundle/SearchBundle/Resources/views/form.html.twig @@ -0,0 +1,45 @@ +{#This form has no form type. I could use a form type but there is no data binding with a model#} +{#and also I don't currently need any validation.#} + +{#For a future release I could move it to a form type if there is a good reason for it.#} + +
+
+
+
+ {% if filters %} + + + {% endif %} +
+ + + + + +
+
+
+ +{#sloppy part TODO: should this be in a file? #} + \ No newline at end of file diff --git a/src/Sylius/Bundle/SearchBundle/Resources/views/search.html.twig b/src/Sylius/Bundle/SearchBundle/Resources/views/search.html.twig new file mode 100644 index 0000000000..806858edc0 --- /dev/null +++ b/src/Sylius/Bundle/SearchBundle/Resources/views/search.html.twig @@ -0,0 +1,14 @@ +{% extends 'SyliusWebBundle:Frontend:layout.html.twig' %} +{% from 'SyliusWebBundle:Frontend/Product:macros.html.twig' import grid %} +{% from 'SyliusWebBundle:Frontend/Macros:misc.html.twig' import pagination %} + +{% block content %} + + {% if results %} + {{ pagination(results) }} + {{ grid(results) }} + {{ pagination(results) }} + {% endif %} + +{% endblock %} + diff --git a/src/Sylius/Bundle/SearchBundle/SyliusSearchBundle.php b/src/Sylius/Bundle/SearchBundle/SyliusSearchBundle.php new file mode 100644 index 0000000000..4bf88af1dd --- /dev/null +++ b/src/Sylius/Bundle/SearchBundle/SyliusSearchBundle.php @@ -0,0 +1,23 @@ + + */ +class SyliusSearchBundle extends Bundle +{ +} diff --git a/src/Sylius/Bundle/SearchBundle/composer.json b/src/Sylius/Bundle/SearchBundle/composer.json new file mode 100644 index 0000000000..e0f26b0ed4 --- /dev/null +++ b/src/Sylius/Bundle/SearchBundle/composer.json @@ -0,0 +1,49 @@ +{ + "name": "sylius/search-bundle", + "type": "symfony-bundle", + "description": "Product catalog for your Symfony2 applications.", + "keywords": ["shop", "ecommerce", "products", "product", "search"], + "homepage": "http://sylius.org", + "license": "MIT", + "authors": [ + { + "name": "Argyrios Gounaris", + "homepage": "http://agounaris.github.io" + }, + { + "name": "Sylius project", + "homepage": "http://sylius.org" + }, + { + "name": "Community contributions", + "homepage": "http://github.com/Sylius/Sylius/contributors" + } + ], + "require": { + "php": ">=5.3.3", + + "friendsofsymfony/elastica-bundle": "~3.0.2", + "twig/twig": "~1.11", + "doctrine/orm": "~2.3" + }, + "require-dev": { + "phpspec/phpspec": "~2.0", + "twig/twig": "~1.11", + "doctrine/orm": "~2.3", + }, + "config": { + "bin-dir": "bin" + }, + "autoload": { + "psr-4": { "Sylius\\Bundle\\SearchBundle\\": "" } + }, + "autoload-dev": { + "psr-4": { "Sylius\\Bundle\\SearchBundle\\spec\\": "spec/" } + }, + "extra": { + "branch-alias": { + "dev-master": "0.11-dev" + } + }, + "minimum-stability": "dev" +} diff --git a/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Doctrine/ORM/SyliusSearchIndexRepositorySpec.php b/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Doctrine/ORM/SyliusSearchIndexRepositorySpec.php new file mode 100644 index 0000000000..029603a2b9 --- /dev/null +++ b/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Doctrine/ORM/SyliusSearchIndexRepositorySpec.php @@ -0,0 +1,36 @@ +beConstructedWith($em, $productRepository); + } + + function it_is_initializable() + { + $this->shouldHaveType('Sylius\Bundle\SearchBundle\Doctrine\ORM\SyliusSearchIndexRepository'); + } + +} \ No newline at end of file diff --git a/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Entity/SearchLogSpec.php b/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Entity/SearchLogSpec.php new file mode 100644 index 0000000000..6ed334250b --- /dev/null +++ b/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Entity/SearchLogSpec.php @@ -0,0 +1,50 @@ + + */ +class SearchLogSpec extends ObjectBehavior +{ + + function it_is_initializable() + { + $this->shouldHaveType('Sylius\Bundle\SearchBundle\Entity\SearchLog'); + } + + function its_search_string_is_mutable() + { + $this->setSearchString('black'); + $this->getSearchString()->shouldReturn('black'); + } + + function its_client_is_mutable() + { + $this->setClient('Mozilla/5.0'); + $this->getClient()->shouldReturn('Mozilla/5.0'); + } + + function its_remote_address_is_mutable() + { + $this->setRemoteAddress('100.100.100.100'); + $this->getRemoteAddress()->shouldReturn('100.100.100.100'); + } + + function its_created_at_is_mutable() + { + $this->setCreatedAt('2014-08-08 16:18:00'); + $this->getCreatedAt()->shouldReturn('2014-08-08 16:18:00'); + } + +} \ No newline at end of file diff --git a/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Entity/SyliusSearchIndexSpec.php b/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Entity/SyliusSearchIndexSpec.php new file mode 100644 index 0000000000..45173957fd --- /dev/null +++ b/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Entity/SyliusSearchIndexSpec.php @@ -0,0 +1,56 @@ + + */ +class SyliusSearchIndexSpec extends ObjectBehavior +{ + function it_is_initializable() + { + $this->shouldHaveType('Sylius\Bundle\SearchBundle\Entity\SyliusSearchIndex'); + } + + function its_item_id_is_mutable() + { + $this->setItemId(20); + $this->getItemId()->shouldReturn(20); + } + + function its_entity_is_mutable() + { + $this->setEntity('Sylius\Component\Core\Model\Product'); + $this->getEntity()->shouldReturn('Sylius\Component\Core\Model\Product'); + } + + function its_value_is_mutable() + { + $this->setValue('black t-shirt'); + $this->getValue()->shouldReturn('black t-shirt'); + } + + function its_tags_is_mutable() + { + $this->setTags('a:4:{s:6:"taxons";a:0:{}s:5:"price";i:5400;s:7:"made_of";a:0:{}s:5:"color";a:0:{}}'); + $this->getTags()->shouldReturn('a:4:{s:6:"taxons";a:0:{}s:5:"price";i:5400;s:7:"made_of";a:0:{}s:5:"color";a:0:{}}'); + } + + function its_created_at_is_mutable() + { + $this->setCreatedAt('2014-08-08 15:53:07'); + $this->getCreatedAt()->shouldReturn('2014-08-08 15:53:07'); + } + +} \ No newline at end of file diff --git a/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Finder/ElasticsearchFinderSpec.php b/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Finder/ElasticsearchFinderSpec.php new file mode 100644 index 0000000000..fc4524696c --- /dev/null +++ b/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Finder/ElasticsearchFinderSpec.php @@ -0,0 +1,84 @@ + + */ +class ElasticsearchFinderSpec extends ObjectBehavior +{ + function let( + SyliusSearchIndexRepository $searchRepository, + $config, + $productRepository, + $container + ) + { + $this->beConstructedWith( + $searchRepository, + $config, + $productRepository, + $container + ); + } + + function it_is_initializable() + { + $this->shouldHaveType('Sylius\Bundle\SearchBundle\Finder\ElasticsearchFinder'); + } + + function it_compiles_an_elasticsearch_query() + { + $config = array( + 'filters' => array( + 'facets' => array( + 'taxons' => array( + 'display_name' => 'Basic categories', + 'type' => 'terms', + 'value' => null, + 'values' => array(), + ), + 'price' => array( + 'display_name' => 'Available prices', + 'type' => 'range', + 'value' => null, + 'values' => array( + array('from' => 0, 'to' => 2000), + array('from' => 2001, 'to' => 5000), + array('from' => 5001, 'to' => 10000), + ), + ), + 'made_of' => array( + 'display_name' => 'Material', + 'type' => 'terms', + 'value' => null, + 'values' => array(), + ), + 'color' => array( + 'display_name' => 'Available colors', + 'type' => 'terms', + 'value' => null, + 'values' => array(), + ), + ) + ) + + ); + + $this->compileElasticsearchQuery('modi', null, $config, null)->shouldHaveType('\Elastica\Query'); + + } + +} diff --git a/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Finder/OrmFinderSpec.php b/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Finder/OrmFinderSpec.php new file mode 100644 index 0000000000..b193141328 --- /dev/null +++ b/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Finder/OrmFinderSpec.php @@ -0,0 +1,172 @@ + + */ +class OrmFinderSpec extends ObjectBehavior +{ + function let( + SyliusSearchIndexRepository $searchRepository, + $config, + $productRepository, + $container, + EntityManager $entityManager + ) + { + $this->beConstructedWith( + $searchRepository, + (array)$config, + $productRepository, + $container, + $entityManager + ); + } + + function it_is_initializable() + { + $this->shouldHaveType('Sylius\Bundle\SearchBundle\Finder\OrmFinder'); + } + + function it_calculates_the_new_facets() + { + $idsFromOtherFacets = array( + 0 => 89, + 1 => 67, + 2 => 30, + 3 => 103, + 4 => 40, + 5 => 62, + 6 => 1, + 7 => 42, + 8 => 117, + ); + + $ormFacets = array( + 'taxons' => array( + 'display_name' => 'Basic categories', + 'type' => 'terms', + 'value' => null, + 'values' => array(), + ), + 'price' => array( + 'display_name' => 'Available prices', + 'type' => 'range', + 'value' => null, + 'values' => array( + array('from' => 0, 'to' => 2000), + array('from' => 2001, 'to' => 5000), + array('from' => 5001, 'to' => 10000), + ), + ), + 'made_of' => array( + 'display_name' => 'Material', + 'type' => 'terms', + 'value' => null, + 'values' => array(), + ), + 'color' => array( + 'display_name' => 'Available colors', + 'type' => 'terms', + 'value' => null, + 'values' => array(), + ), + ); + + $givenFacetName = 'taxons'; + + $result = array( + 89 => 'a:4:{s:6:"taxons";a:2:{i:0;s:5:"Books";i:1;s:9:"Bookmania";}s:5:"price";i:705;s:7:"made_of";a:0:{}s:5:"color";a:0:{}}', + 67 => 'a:4:{s:6:"taxons";a:2:{i:0;s:8:"T-Shirts";i:1;s:9:"SuperTees";}s:5:"price";i:2840;s:7:"made_of";a:1:{i:0;s:9:"Polyester";}s:5:"color";a:3:{i:0;s:3:"Red";i:1;s:4:"Blue";i:2;s:5:"Green";}}', + 30 => 'a:4:{s:6:"taxons";a:2:{i:0;s:5:"Books";i:1;s:9:"Bookmania";}s:5:"price";i:3905;s:7:"made_of";a:0:{}s:5:"color";a:0:{}}', + 103 => 'a:4:{s:6:"taxons";a:2:{i:0;s:8:"T-Shirts";i:1;s:9:"SuperTees";}s:5:"price";i:6222;s:7:"made_of";a:1:{i:0;s:24:"Polyester 10% / Wool 90%";}s:5:"color";a:3:{i:0;s:3:"Red";i:1;s:4:"Blue";i:2;s:5:"Green";}}', + 40 => 'a:4:{s:6:"taxons";a:2:{i:0;s:5:"Books";i:1;s:9:"Bookmania";}s:5:"price";i:4089;s:7:"made_of";a:0:{}s:5:"color";a:0:{}}', + 62 => 'a:4:{s:6:"taxons";a:2:{i:0;s:5:"Books";i:1;s:9:"Bookmania";}s:5:"price";i:5979;s:7:"made_of";a:0:{}s:5:"color";a:0:{}}', + 1 => 'a:4:{s:6:"taxons";a:2:{i:0;s:5:"Books";i:1;s:9:"Bookmania";}s:5:"price";i:449;s:7:"made_of";a:0:{}s:5:"color";a:0:{}}', + 42 => 'a:4:{s:6:"taxons";a:2:{i:0;s:8:"Stickers";i:1;s:11:"Stickypicky";}s:5:"price";i:8330;s:7:"made_of";a:0:{}s:5:"color";a:0:{}}', + 117 => 'a:4:{s:6:"taxons";a:2:{i:0;s:5:"Books";i:1;s:9:"Bookmania";}s:5:"price";i:4188;s:7:"made_of";a:0:{}s:5:"color";a:0:{}}', + ); + + $this->getFacet($idsFromOtherFacets, $ormFacets, $givenFacetName, $result)->shouldHaveCount(6); + + } + + public function it_calculates_the_raw_facets() + { + $idsFromOtherFacets = array( + 0 => 89, + 1 => 67, + 2 => 30, + 3 => 103, + 4 => 40, + 5 => 62, + 6 => 1, + 7 => 42, + 8 => 117, + ); + + $result = array( + 89 => 'a:4:{s:6:"taxons";a:2:{i:0;s:5:"Books";i:1;s:9:"Bookmania";}s:5:"price";i:705;s:7:"made_of";a:0:{}s:5:"color";a:0:{}}', + 67 => 'a:4:{s:6:"taxons";a:2:{i:0;s:8:"T-Shirts";i:1;s:9:"SuperTees";}s:5:"price";i:2840;s:7:"made_of";a:1:{i:0;s:9:"Polyester";}s:5:"color";a:3:{i:0;s:3:"Red";i:1;s:4:"Blue";i:2;s:5:"Green";}}', + 30 => 'a:4:{s:6:"taxons";a:2:{i:0;s:5:"Books";i:1;s:9:"Bookmania";}s:5:"price";i:3905;s:7:"made_of";a:0:{}s:5:"color";a:0:{}}', + 103 => 'a:4:{s:6:"taxons";a:2:{i:0;s:8:"T-Shirts";i:1;s:9:"SuperTees";}s:5:"price";i:6222;s:7:"made_of";a:1:{i:0;s:24:"Polyester 10% / Wool 90%";}s:5:"color";a:3:{i:0;s:3:"Red";i:1;s:4:"Blue";i:2;s:5:"Green";}}', + 40 => 'a:4:{s:6:"taxons";a:2:{i:0;s:5:"Books";i:1;s:9:"Bookmania";}s:5:"price";i:4089;s:7:"made_of";a:0:{}s:5:"color";a:0:{}}', + 62 => 'a:4:{s:6:"taxons";a:2:{i:0;s:5:"Books";i:1;s:9:"Bookmania";}s:5:"price";i:5979;s:7:"made_of";a:0:{}s:5:"color";a:0:{}}', + 1 => 'a:4:{s:6:"taxons";a:2:{i:0;s:5:"Books";i:1;s:9:"Bookmania";}s:5:"price";i:449;s:7:"made_of";a:0:{}s:5:"color";a:0:{}}', + 42 => 'a:4:{s:6:"taxons";a:2:{i:0;s:8:"Stickers";i:1;s:11:"Stickypicky";}s:5:"price";i:8330;s:7:"made_of";a:0:{}s:5:"color";a:0:{}}', + 117 => 'a:4:{s:6:"taxons";a:2:{i:0;s:5:"Books";i:1;s:9:"Bookmania";}s:5:"price";i:4188;s:7:"made_of";a:0:{}s:5:"color";a:0:{}}', + ); + + $this->calculateRawFacets($idsFromOtherFacets, $result)->shouldHaveCount(4); + + } + + public function it_performs_a_fulltext_query( + EntityManagerInterface $entityManager, + Connection $connection, + PDOStatement $pdoStmt) + { + $result = array( + 0 => + array( + 'item_id' => '89', + 'tags' => 'a:4:{s:6:"taxons";a:2:{i:0;s:5:"Books";i:1;s:9:"Bookmania";}s:5:"price";i:705;s:7:"made_of";a:0:{}s:5:"color";a:0:{}}', + 'entity' => 'Sylius\Component\Core\Model\Product'), + 1 => + array( + 'item_id' => '67', + 'tags' => 'a:4:{s:6:"taxons";a:2:{i:0;s:8:"T-Shirts";i:1;s:9:"SuperTees";}s:5:"price";i:2840;s:7:"made_of";a:1:{i:0;s:9:"Polyester";}s:5:"color";a:3:{i:0;s:3:"Red";i:1;s:4:"Blue";i:2;s:5:"Green";}}', + 'entity' => 'Sylius\Component\Core\Model\Product') + ); + + $entityManager->getConnection()->shouldBeCalled()->willReturn($connection); + + $connection->prepare(Argument::any())->shouldBeCalled()->willReturn($pdoStmt); + + $pdoStmt->execute(Argument::any())->shouldBeCalled(); + + $pdoStmt->fetchAll()->willReturn($result); + + $this->query('black', $entityManager, false)->shouldHaveCount(2); + } + +} diff --git a/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Indexer/ElasticsearchIndexerSpec.php b/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Indexer/ElasticsearchIndexerSpec.php new file mode 100644 index 0000000000..b335fd9767 --- /dev/null +++ b/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Indexer/ElasticsearchIndexerSpec.php @@ -0,0 +1,41 @@ + + */ +class ElasticsearchIndexerSpec extends ObjectBehavior +{ + function let( + Kernel $kernel + ) + { + $this->beConstructedWith( + $kernel + ); + } + + function it_is_initializable() + { + $this->shouldHaveType('Sylius\Bundle\SearchBundle\Indexer\ElasticsearchIndexer'); + } + + function it_implements_the_indexer_interface_interface() + { + $this->shouldImplement('Sylius\Bundle\SearchBundle\Indexer\IndexerInterface'); + } +} diff --git a/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Indexer/OrmIndexerSpec.php b/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Indexer/OrmIndexerSpec.php new file mode 100644 index 0000000000..3b572a8655 --- /dev/null +++ b/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Indexer/OrmIndexerSpec.php @@ -0,0 +1,43 @@ + + */ +class OrmIndexerSpec extends ObjectBehavior +{ + function let( + $config, + ModelToElasticaAutoTransformer $transformer + ) + { + $this->beConstructedWith( + (Array)$config, + $transformer + ); + } + + function it_is_initializable() + { + $this->shouldHaveType('Sylius\Bundle\SearchBundle\Indexer\OrmIndexer'); + } + + function it_implements_the_indexer_interface_interface() + { + $this->shouldImplement('Sylius\Bundle\SearchBundle\Indexer\IndexerInterface'); + } +} diff --git a/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Listener/OrmListenerSpec.php b/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Listener/OrmListenerSpec.php new file mode 100644 index 0000000000..1a6b05385b --- /dev/null +++ b/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Listener/OrmListenerSpec.php @@ -0,0 +1,57 @@ + + */ +class OrmListenerSpec extends ObjectBehavior +{ + + function let(OrmIndexer $ormIndexer) + { + $this->beConstructedWith($ormIndexer); + } + + function it_is_initializable() + { + $this->shouldHaveType('Sylius\Bundle\SearchBundle\Listener\OrmListener'); + } + + function it_populates_the_insertion_array(LifecycleEventArgs $args) + { + $this->postPersist($args); + + $this->scheduledForInsertion->shouldBeArray(); + } + + function it_populates_the_update_array(LifecycleEventArgs $args) + { + $this->postUpdate($args); + + $this->scheduledForInsertion->shouldBeArray(); + } + + function it_populates_the_delete_array(LifecycleEventArgs $args) + { + $this->preRemove($args); + + $this->scheduledForDeletion->shouldBeArray(); + } + +} \ No newline at end of file diff --git a/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Query/SearchStringQuerySpec.php b/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Query/SearchStringQuerySpec.php new file mode 100644 index 0000000000..34f74cef78 --- /dev/null +++ b/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Query/SearchStringQuerySpec.php @@ -0,0 +1,52 @@ + + */ +class SearchStringQuerySpec extends ObjectBehavior +{ + + function let() + { + $this->beConstructedWith('search term', 'all', array('test'), true); + } + + function it_is_initializable() + { + $this->shouldHaveType('Sylius\Bundle\SearchBundle\Query\SearchStringQuery'); + } + + public function it_has_a_search_term() + { + $this->getSearchTerm()->shouldReturn('search term'); + } + + public function it_has_a_search_param() + { + $this->getSearchParam()->shouldReturn('all'); + } + + public function it_has_some_applied_filters() + { + $this->getAppliedFilters()->shouldReturn(array('test')); + } + + public function it_should_take_in_mind_the_dropdown_filter() + { + $this->isDropdownFilterEnabled()->shouldReturn(true); + } + +} \ No newline at end of file diff --git a/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Query/TaxonQuerySpec.php b/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Query/TaxonQuerySpec.php new file mode 100644 index 0000000000..420a8923a1 --- /dev/null +++ b/src/Sylius/Bundle/SearchBundle/spec/Sylius/Bundle/SearchBundle/Query/TaxonQuerySpec.php @@ -0,0 +1,43 @@ + + */ +class TaxonQuerySpec extends ObjectBehavior +{ + + function let(Taxon $taxon) + { + $this->beConstructedWith($taxon, array('test')); + } + + function it_is_initializable() + { + $this->shouldHaveType('Sylius\Bundle\SearchBundle\Query\TaxonQuery'); + } + + public function it_has_a_taxon() + { + $this->getTaxon()->shouldBeAnInstanceOf('Sylius\Component\Core\Model\Taxon'); + } + + public function it_has_some_applied_filters() + { + $this->getAppliedFilters()->shouldReturn(array('test')); + } + +} \ No newline at end of file diff --git a/src/Sylius/Bundle/WebBundle/Resources/config/routing/frontend/main.yml b/src/Sylius/Bundle/WebBundle/Resources/config/routing/frontend/main.yml index 7f77b695aa..538cdc5e1a 100644 --- a/src/Sylius/Bundle/WebBundle/Resources/config/routing/frontend/main.yml +++ b/src/Sylius/Bundle/WebBundle/Resources/config/routing/frontend/main.yml @@ -46,3 +46,7 @@ sylius_cart_save: sylius_contact: resource: @SyliusWebBundle/Resources/config/routing/frontend/contact.yml prefix: /contact + +sylius_search: + resource: @SyliusWebBundle/Resources/config/routing/frontend/search.yml + prefix: /search diff --git a/src/Sylius/Bundle/WebBundle/Resources/config/routing/frontend/search.yml b/src/Sylius/Bundle/WebBundle/Resources/config/routing/frontend/search.yml new file mode 100644 index 0000000000..e719bbf0dc --- /dev/null +++ b/src/Sylius/Bundle/WebBundle/Resources/config/routing/frontend/search.yml @@ -0,0 +1,7 @@ +# This file is part of the Sylius package. +# (c) Paweł Jędrzejewski + +sylius_search_homepage: + pattern: / + defaults: + _controller: SyliusSearchBundle:Search:index diff --git a/src/Sylius/Bundle/WebBundle/Resources/views/Frontend/layout.html.twig b/src/Sylius/Bundle/WebBundle/Resources/views/Frontend/layout.html.twig index 1609511ae2..e794f7182c 100644 --- a/src/Sylius/Bundle/WebBundle/Resources/views/Frontend/layout.html.twig +++ b/src/Sylius/Bundle/WebBundle/Resources/views/Frontend/layout.html.twig @@ -76,7 +76,12 @@