├── tests ├── Application │ ├── var │ │ └── .gitkeep │ ├── .gitignore │ ├── app │ │ ├── config │ │ │ ├── config_test.yml │ │ │ ├── config_dev.yml │ │ │ ├── routing_dev.yml │ │ │ ├── routing.yml │ │ │ ├── config.yml │ │ │ └── security.yml │ │ └── AppKernel.php │ ├── package.json │ ├── Gulpfile.js │ └── bin │ │ └── console ├── Behat │ └── Resources │ │ ├── suites.yml │ │ └── services.xml ├── Responses │ └── Expected │ │ └── WEB_GB │ │ ├── en_GB │ │ ├── product_list_page_filtered_by_production_year_2015_attribute.json │ │ ├── product_list_page_filtered_by_production_year_2015_or_2020_and_mug_material_wood_attribute.json │ │ ├── product_list_page_filtered_by_partial_phrase.json │ │ ├── product_list_page_filtered_by_production_year_2015_or_2020_attribute.json │ │ ├── product_list_page_filtered_by_phrase.json │ │ ├── second_product_list_page_limited_to_two.json │ │ ├── first_product_list_page_limited_to_two.json │ │ ├── product_list_page_filtered_by_price_range.json │ │ └── product_list_page_filtered_by_mug_material_wood_attribute.json │ │ └── de_DE │ │ ├── product_list_page_filtered_by_production_year_2015_attribute.json │ │ ├── product_list_page_filtered_by_phrase.json │ │ └── product_list_page_filtered_by_mug_material_wood_attribute.json ├── Event │ └── ProductCreatedTest.php ├── DependencyInjection │ ├── ConfigurationTest.php │ └── SyliusElasticSearchExtensionTest.php └── Factory │ └── ProductDocumentFactoryTest.php ├── .gitignore ├── easy-coding-standard.neon ├── phpspec.yml.dist ├── src ├── Controller │ ├── ImageView.php │ ├── TaxonView.php │ ├── PriceView.php │ ├── AttributeView.php │ ├── ProductListView.php │ ├── VariantView.php │ ├── ProductView.php │ └── SearchController.php ├── Factory │ ├── Document │ │ ├── ImageDocumentFactoryInterface.php │ │ ├── TaxonDocumentFactoryInterface.php │ │ ├── PriceDocumentFactoryInterface.php │ │ ├── OptionDocumentFactoryInterface.php │ │ ├── VariantDocumentFactoryInterface.php │ │ ├── AttributeDocumentFactoryInterface.php │ │ ├── ProductDocumentFactoryInterface.php │ │ ├── ImageDocumentFactory.php │ │ ├── PriceDocumentFactory.php │ │ ├── OptionDocumentFactory.php │ │ ├── AttributeDocumentFactory.php │ │ ├── TaxonDocumentFactory.php │ │ ├── VariantDocumentFactory.php │ │ └── ProductDocumentFactory.php │ └── View │ │ ├── ProductListViewFactoryInterface.php │ │ └── ProductListViewFactory.php ├── Filter │ ├── ViewData │ │ ├── EmptyViewData.php │ │ └── PagerAwareViewData.php │ └── Widget │ │ ├── MultiDynamicAggregateWithoutView.php │ │ ├── SingleNestedTermChoice.php │ │ ├── Pager.php │ │ ├── MultiDynamicAggregateOverride.php │ │ ├── InStock.php │ │ ├── Sort.php │ │ └── OptionMultiDynamicAggregate.php ├── Exception │ └── UnsupportedFactoryMethodException.php ├── SyliusElasticSearchPlugin.php ├── Resources │ └── config │ │ ├── routing.yml │ │ ├── services │ │ ├── controller.xml │ │ ├── command.xml │ │ └── factories.xml │ │ ├── app │ │ └── config.yml │ │ └── services.xml ├── Event │ ├── ProductCreated.php │ ├── ProductDeleted.php │ └── ProductUpdated.php ├── Document │ ├── ImageDocument.php │ ├── PriceDocument.php │ ├── AttributeDocument.php │ ├── OptionDocument.php │ ├── TaxonDocument.php │ ├── VariantDocument.php │ └── ProductDocument.php ├── DependencyInjection │ ├── SyliusElasticSearchExtension.php │ └── Configuration.php ├── Projection │ └── ProductProjector.php ├── Command │ ├── ResetProductIndexCommand.php │ └── UpdateProductIndexCommand.php └── EventListener │ └── ProductPublisher.php ├── behat.yml.dist ├── README.md ├── phpunit.xml.dist ├── spec ├── Document │ ├── ImageDocumentSpec.php │ ├── PriceDocumentSpec.php │ ├── AttributeDocumentSpec.php │ ├── TaxonDocumentSpec.php │ └── ProductDocumentSpec.php └── Projection │ └── ProductProjectorSpec.php ├── .travis.yml └── composer.json /tests/Application/var/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/Behat/Resources/suites.yml: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /vendor/ 2 | /composer.lock 3 | /bin/ 4 | -------------------------------------------------------------------------------- /tests/Application/.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | 3 | /var/ 4 | !/var/.gitkeep 5 | 6 | /web/ 7 | -------------------------------------------------------------------------------- /tests/Application/app/config/config_test.yml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: "config.yml" } 3 | -------------------------------------------------------------------------------- /easy-coding-standard.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/sylius-labs/coding-standard/easy-coding-standard.neon 3 | -------------------------------------------------------------------------------- /phpspec.yml.dist: -------------------------------------------------------------------------------- 1 | suites: 2 | default: 3 | namespace: Sylius\ElasticSearchPlugin 4 | psr4_prefix: Sylius\ElasticSearchPlugin 5 | -------------------------------------------------------------------------------- /src/Controller/ImageView.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /tests/Application/app/config/config_dev.yml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: "config.yml" } 3 | 4 | framework: 5 | router: { resource: "%kernel.root_dir%/config/routing_dev.yml" } 6 | profiler: { only_exceptions: false } 7 | 8 | swiftmailer: 9 | disable_delivery: true 10 | 11 | web_profiler: 12 | toolbar: true 13 | intercept_redirects: false 14 | -------------------------------------------------------------------------------- /src/Controller/PriceView.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 8 | 9 | 10 | tests/ 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /tests/Application/app/config/routing.yml: -------------------------------------------------------------------------------- 1 | sylius_admin_dashboard_redirect: 2 | path: /admin 3 | defaults: 4 | _controller: FrameworkBundle:Redirect:redirect 5 | route: sylius_admin_dashboard 6 | permanent: true 7 | 8 | sylius_shop: 9 | resource: "@SyliusShopBundle/Resources/config/routing.yml" 10 | 11 | sylius_admin: 12 | resource: "@SyliusAdminBundle/Resources/config/routing.yml" 13 | prefix: /admin 14 | 15 | sylius_api: 16 | resource: "@SyliusAdminApiBundle/Resources/config/routing/main.yml" 17 | prefix: /api 18 | 19 | sylius_search: 20 | resource: "@SyliusElasticSearchPlugin/Resources/config/routing.yml" 21 | prefix: / 22 | -------------------------------------------------------------------------------- /tests/Responses/Expected/WEB_GB/en_GB/product_list_page_filtered_by_production_year_2015_attribute.json: -------------------------------------------------------------------------------- 1 | { 2 | "page": 1, 3 | "limit": 9, 4 | "total": 1, 5 | "pages": 1, 6 | "items": [ 7 | { 8 | "id": 1, 9 | "code": "LOGAN_MUG_CODE", 10 | "name": "Logan Mug", 11 | "slug": "@string@", 12 | "taxons": "@array@", 13 | "variants": "@array@", 14 | "attributes": "@array@", 15 | "images": "@array@", 16 | "price": "@array@", 17 | "channelCode": "WEB_GB", 18 | "localeCode": "en_GB" 19 | } 20 | ], 21 | "filters": "@array@" 22 | } 23 | -------------------------------------------------------------------------------- /tests/Responses/Expected/WEB_GB/de_DE/product_list_page_filtered_by_production_year_2015_attribute.json: -------------------------------------------------------------------------------- 1 | { 2 | "page": 1, 3 | "limit": 9, 4 | "total": 1, 5 | "pages": 1, 6 | "items": [ 7 | { 8 | "id": 1, 9 | "code": "LOGAN_MUG_CODE", 10 | "name": "Logan Becher", 11 | "slug": "@string@", 12 | "taxons": "@array@", 13 | "variants": "@array@", 14 | "attributes": "@array@", 15 | "images": "@array@", 16 | "price": "@array@", 17 | "channelCode": "WEB_GB", 18 | "localeCode": "de_DE" 19 | } 20 | ], 21 | "filters": "@array@" 22 | } 23 | -------------------------------------------------------------------------------- /src/Controller/ProductListView.php: -------------------------------------------------------------------------------- 1 | prophesize(ProductInterface::class); 19 | $event = ProductCreated::occur($product->reveal()); 20 | 21 | $this->assertEquals($product->reveal(), $event->product()); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /spec/Document/ImageDocumentSpec.php: -------------------------------------------------------------------------------- 1 | shouldHaveType(ImageDocument::class); 15 | } 16 | 17 | function it_has_code() 18 | { 19 | $this->setCode('abc'); 20 | 21 | $this->getCode()->shouldReturn('abc'); 22 | } 23 | 24 | function it_has_path() 25 | { 26 | $this->setPath('/abc'); 27 | 28 | $this->getPath()->shouldReturn('/abc'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /spec/Document/PriceDocumentSpec.php: -------------------------------------------------------------------------------- 1 | shouldHaveType(PriceDocument::class); 15 | } 16 | 17 | function it_has_amount() 18 | { 19 | $this->setAmount(1000); 20 | 21 | $this->getAmount()->shouldReturn(1000); 22 | } 23 | 24 | function it_has_currency() 25 | { 26 | $this->setCurrency('EUR'); 27 | 28 | $this->getCurrency()->shouldReturn('EUR'); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Factory/Document/AttributeDocumentFactoryInterface.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /spec/Document/AttributeDocumentSpec.php: -------------------------------------------------------------------------------- 1 | shouldHaveType(AttributeDocument::class); 15 | } 16 | 17 | function it_has_code() 18 | { 19 | $this->setCode('color'); 20 | 21 | $this->getCode()->shouldReturn('color'); 22 | } 23 | 24 | function it_has_name() 25 | { 26 | $this->setName('color'); 27 | 28 | $this->getName()->shouldReturn('color'); 29 | } 30 | 31 | function it_has_value() 32 | { 33 | $this->setValue('red'); 34 | 35 | $this->getValue()->shouldReturn('red'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/Application/Gulpfile.js: -------------------------------------------------------------------------------- 1 | var gulp = require('gulp'); 2 | var chug = require('gulp-chug'); 3 | var argv = require('yargs').argv; 4 | 5 | config = [ 6 | '--rootPath', 7 | argv.rootPath || '../../../../../../../tests/Application/web/assets/', 8 | '--nodeModulesPath', 9 | argv.nodeModulesPath || '../../../../../../../tests/Application/node_modules/', 10 | '--vendorPath', 11 | argv.vendorPath || '../../../../../../../vendor/' 12 | ]; 13 | 14 | gulp.task('admin', function() { 15 | gulp.src('../../vendor/sylius/sylius/src/Sylius/Bundle/AdminBundle/Gulpfile.js', { read: false }) 16 | .pipe(chug({ args: config })) 17 | ; 18 | }); 19 | 20 | gulp.task('shop', function() { 21 | gulp.src('../../vendor/sylius/sylius/src/Sylius/Bundle/ShopBundle/Gulpfile.js', { read: false }) 22 | .pipe(chug({ args: config })) 23 | ; 24 | }); 25 | 26 | gulp.task('default', ['admin', 'shop']); 27 | -------------------------------------------------------------------------------- /src/Event/ProductCreated.php: -------------------------------------------------------------------------------- 1 | product = $product; 22 | } 23 | 24 | /** 25 | * @param ProductInterface $product 26 | * 27 | * @return self 28 | */ 29 | public static function occur(ProductInterface $product) 30 | { 31 | return new self($product); 32 | } 33 | 34 | /** 35 | * @return ProductInterface 36 | */ 37 | public function product(): ProductInterface 38 | { 39 | return $this->product; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Event/ProductDeleted.php: -------------------------------------------------------------------------------- 1 | product = $product; 22 | } 23 | 24 | /** 25 | * @param ProductInterface $product 26 | * 27 | * @return self 28 | */ 29 | public static function occur(ProductInterface $product) 30 | { 31 | return new self($product); 32 | } 33 | 34 | /** 35 | * @return ProductInterface 36 | */ 37 | public function product(): ProductInterface 38 | { 39 | return $this->product; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Event/ProductUpdated.php: -------------------------------------------------------------------------------- 1 | product = $product; 22 | } 23 | 24 | /** 25 | * @param ProductInterface $product 26 | * 27 | * @return self 28 | */ 29 | public static function occur(ProductInterface $product) 30 | { 31 | return new self($product); 32 | } 33 | 34 | /** 35 | * @return ProductInterface 36 | */ 37 | public function product(): ProductInterface 38 | { 39 | return $this->product; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Factory/Document/ImageDocumentFactory.php: -------------------------------------------------------------------------------- 1 | imageDocumentClass = $imageDocumentClass; 18 | } 19 | 20 | public function create(ImageInterface $image): ImageDocument 21 | { 22 | /** @var ImageDocument $imageDocument */ 23 | $imageDocument = new $this->imageDocumentClass(); 24 | $imageDocument->setCode($image->getType()); 25 | $imageDocument->setPath($image->getPath()); 26 | 27 | return $imageDocument; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /tests/Application/bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getParameterOption(['--env', '-e'], getenv('SYMFONY_ENV') ?: 'dev'); 19 | $debug = getenv('SYMFONY_DEBUG') !== '0' && !$input->hasParameterOption(['--no-debug', '']) && $env !== 'prod'; 20 | 21 | if ($debug) { 22 | Debug::enable(); 23 | } 24 | 25 | $kernel = new AppKernel($env, $debug); 26 | $application = new Application($kernel); 27 | $application->run($input); 28 | -------------------------------------------------------------------------------- /src/Filter/ViewData/PagerAwareViewData.php: -------------------------------------------------------------------------------- 1 | limit = $itemsPerPage; 27 | } 28 | 29 | /** 30 | * {@inheritdoc} 31 | */ 32 | public function getSerializableData() 33 | { 34 | $data = parent::getSerializableData(); 35 | 36 | $data['pager']['limit'] = $this->limit; 37 | 38 | return $data; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /tests/Responses/Expected/WEB_GB/en_GB/product_list_page_filtered_by_partial_phrase.json: -------------------------------------------------------------------------------- 1 | { 2 | "page": 1, 3 | "limit": 9, 4 | "total": 2, 5 | "pages": 1, 6 | "items": [ 7 | { 8 | "id": 1, 9 | "code": "LOGAN_MUG_CODE", 10 | "name": "Logan Mug", 11 | "slug": "@string@", 12 | "taxons": "@array@", 13 | "variants": "@array@", 14 | "attributes": "@array@", 15 | "images": "@array@", 16 | "price": "@array@", 17 | "channelCode": "WEB_GB", 18 | "localeCode": "en_GB" 19 | }, 20 | { 21 | "id": 2, 22 | "code": "LOGAN_HAT_CODE", 23 | "name": "Logan Hat", 24 | "slug": "@string@", 25 | "taxons": "@array@", 26 | "variants": "@array@", 27 | "attributes": "@array@", 28 | "images": "@array@", 29 | "price": "@array@", 30 | "channelCode": "WEB_GB", 31 | "localeCode": "en_GB" 32 | } 33 | ], 34 | "filters": "@array@" 35 | } 36 | -------------------------------------------------------------------------------- /src/Resources/config/services/command.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /tests/Responses/Expected/WEB_GB/en_GB/product_list_page_filtered_by_production_year_2015_or_2020_attribute.json: -------------------------------------------------------------------------------- 1 | { 2 | "page": 1, 3 | "limit": 9, 4 | "total": 2, 5 | "pages": 1, 6 | "items": [ 7 | { 8 | "id": 1, 9 | "code": "LOGAN_MUG_CODE", 10 | "name": "Logan Mug", 11 | "slug": "@string@", 12 | "taxons": "@array@", 13 | "variants": "@array@", 14 | "attributes": "@array@", 15 | "images": "@array@", 16 | "price": "@array@", 17 | "channelCode": "WEB_GB", 18 | "localeCode": "en_GB" 19 | }, 20 | { 21 | "id": 3, 22 | "code": "LOGAN_T_SHIRT_CODE", 23 | "name": "Logan T-Shirt", 24 | "slug": "@string@", 25 | "taxons": "@array@", 26 | "variants": "@array@", 27 | "attributes": "@array@", 28 | "images": "@array@", 29 | "price": "@array@", 30 | "channelCode": "WEB_GB", 31 | "localeCode": "en_GB" 32 | } 33 | ], 34 | "filters": "@array@" 35 | } 36 | -------------------------------------------------------------------------------- /src/Filter/Widget/SingleNestedTermChoice.php: -------------------------------------------------------------------------------- 1 | ', $this->getDocumentField()); 25 | 26 | if ($state && $state->isActive()) { 27 | $search->addPostFilter( 28 | new NestedQuery( 29 | $path, 30 | new TermQuery($field, $state->getValue()) 31 | ) 32 | ); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /spec/Document/TaxonDocumentSpec.php: -------------------------------------------------------------------------------- 1 | shouldHaveType(TaxonDocument::class); 16 | } 17 | 18 | function it_has_code() 19 | { 20 | $this->setCode('mug'); 21 | 22 | $this->getCode()->shouldReturn('mug'); 23 | } 24 | 25 | function it_has_slug() 26 | { 27 | $this->setSlug('/mug'); 28 | 29 | $this->getSlug()->shouldReturn('/mug'); 30 | } 31 | 32 | function it_has_images() 33 | { 34 | $images = new Collection(); 35 | $this->setImages($images); 36 | 37 | $this->getImages()->shouldReturn($images); 38 | } 39 | 40 | function it_has_description() 41 | { 42 | $this->setDescription('Lorem ipsum'); 43 | 44 | $this->getDescription()->shouldReturn('Lorem ipsum'); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/Document/ImageDocument.php: -------------------------------------------------------------------------------- 1 | code; 34 | } 35 | 36 | /** 37 | * @param string $code 38 | */ 39 | public function setCode(?string $code): void 40 | { 41 | $this->code = $code; 42 | } 43 | 44 | /** 45 | * @return string 46 | */ 47 | public function getPath(): string 48 | { 49 | return $this->path; 50 | } 51 | 52 | /** 53 | * @param string $path 54 | */ 55 | public function setPath(string $path): void 56 | { 57 | $this->path = $path; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Controller/ProductView.php: -------------------------------------------------------------------------------- 1 | priceDocumentClass = $priceDocumentClass; 21 | } 22 | 23 | public function create( 24 | ChannelPricingInterface $channelPricing, 25 | CurrencyInterface $currency 26 | ): PriceDocument { 27 | /** @var PriceDocument $price */ 28 | $price = new $this->priceDocumentClass(); 29 | $originalAmount = $channelPricing->getOriginalPrice(); 30 | 31 | $price->setAmount($channelPricing->getPrice()); 32 | $price->setCurrency($currency->getCode()); 33 | $price->setOriginalAmount(null !== $originalAmount && $originalAmount > 0 ? $originalAmount : 0); 34 | 35 | return $price; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 7.1 5 | 6 | jdk: 7 | - oraclejdk8 8 | 9 | addons: 10 | apt: 11 | sources: 12 | - elasticsearch-5.x 13 | packages: 14 | - elasticsearch 15 | - oracle-java8-set-default 16 | 17 | services: 18 | - elasticsearch 19 | 20 | cache: 21 | directories: 22 | - vendor 23 | 24 | before_install: 25 | - phpenv config-rm xdebug.ini || true 26 | - echo "memory_limit=4096M" >> ~/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini 27 | 28 | - composer self-update 29 | - composer validate 30 | 31 | install: 32 | - composer update --prefer-dist 33 | - tests/Application/bin/console doctrine:schema:create --env=test -vvv --no-interaction 34 | - tests/Application/bin/console ongr:es:index:create -vvv --no-interaction 35 | 36 | script: 37 | - bin/phpspec run --no-interaction 38 | - bin/phpunit 39 | - tests/Application/bin/console sylius:elastic-search:update-product-index --env=test -vvv && tests/Application/bin/console sylius:elastic-search:update-product-index --env=test -vvv 40 | - tests/Application/bin/console sylius:elastic-search:reset-product-index --env=test --force -vvv && tests/Application/bin/console sylius:elastic-search:reset-product-index --env=test --force -vvv 41 | #- bin/behat --strict -vvv --no-interaction 42 | -------------------------------------------------------------------------------- /src/DependencyInjection/SyliusElasticSearchExtension.php: -------------------------------------------------------------------------------- 1 | processConfiguration($this->getConfiguration([], $container), $configs); 20 | $loader = new XmlFileLoader($container, new FileLocator(__DIR__ . '/../Resources/config')); 21 | 22 | $container->setParameter('sylius_elastic_search.attribute_whitelist', $config['attribute_whitelist']); 23 | 24 | foreach ($config['document_classes'] as $document => $class) { 25 | $container->setParameter(sprintf('sylius_elastic_search.document.%s.class', $document), $class); 26 | } 27 | 28 | foreach ($config['view_classes'] as $view => $class) { 29 | $container->setParameter(sprintf('sylius_elastic_search.view.%s.class', $view), $class); 30 | } 31 | 32 | $loader->load('services.xml'); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Factory/Document/OptionDocumentFactory.php: -------------------------------------------------------------------------------- 1 | optionDocumentClass = $optionDocumentClass; 21 | } 22 | 23 | public function create( 24 | ProductOptionValueInterface $optionValue, 25 | LocaleInterface $locale 26 | ): OptionDocument { 27 | /** @var ProductOptionValueTranslationInterface $optionValueTranslation */ 28 | $optionValueTranslation = $optionValue->getTranslation($locale->getCode()); 29 | 30 | /** @var ProductOptionTranslationInterface $productOptionTranslation */ 31 | $productOptionTranslation = $optionValue->getOption()->getTranslation($locale->getCode()); 32 | 33 | /** @var OptionDocument $option */ 34 | $option = new $this->optionDocumentClass(); 35 | $option->setCode($optionValue->getOptionCode()); 36 | $option->setName($productOptionTranslation->getName()); 37 | $option->setValue($optionValueTranslation->getValue()); 38 | 39 | return $option; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Document/PriceDocument.php: -------------------------------------------------------------------------------- 1 | amount; 41 | } 42 | 43 | /** 44 | * @param int $amount 45 | */ 46 | public function setAmount(int $amount): void 47 | { 48 | $this->amount = $amount; 49 | } 50 | 51 | /** 52 | * @return int 53 | */ 54 | public function getOriginalAmount(): int 55 | { 56 | return $this->originalAmount; 57 | } 58 | 59 | /** 60 | * @param int $originalAmount 61 | */ 62 | public function setOriginalAmount(int $originalAmount = 0): void 63 | { 64 | $this->originalAmount = $originalAmount; 65 | } 66 | 67 | /** 68 | * @return string 69 | */ 70 | public function getCurrency(): string 71 | { 72 | return $this->currency; 73 | } 74 | 75 | /** 76 | * @param string $currency 77 | */ 78 | public function setCurrency(string $currency): void 79 | { 80 | $this->currency = $currency; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/Application/app/AppKernel.php: -------------------------------------------------------------------------------- 1 | load($this->getRootDir() . '/config/config_' . $this->getEnvironment() . '.yml'); 36 | } 37 | 38 | /** 39 | * {@inheritdoc} 40 | */ 41 | public function getCacheDir(): string 42 | { 43 | return sprintf('%s/%s/cache', sys_get_temp_dir(), md5(__DIR__)); 44 | } 45 | 46 | /** 47 | * {@inheritdoc} 48 | */ 49 | public function getLogDir(): string 50 | { 51 | return sprintf('%s/%s/logs', sys_get_temp_dir(), md5(__DIR__)); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /tests/Responses/Expected/WEB_GB/de_DE/product_list_page_filtered_by_phrase.json: -------------------------------------------------------------------------------- 1 | { 2 | "page": 1, 3 | "limit": 9, 4 | "total": 1, 5 | "pages": 1, 6 | "items": [ 7 | { 8 | "id": 2, 9 | "code": "LOGAN_HAT_CODE", 10 | "name": "Logan Hut", 11 | "slug": "@string@", 12 | "taxons": "@array@", 13 | "variants": "@array@", 14 | "attributes": "@array@", 15 | "images": "@array@", 16 | "price": "@array@", 17 | "channelCode": "WEB_GB", 18 | "localeCode": "de_DE" 19 | } 20 | ], 21 | "filters": { 22 | "channel": "@array@", 23 | "enabled": "@array@", 24 | "taxonCode": "@array@", 25 | "taxonSlug": "@array@", 26 | "priceRange": "@array@", 27 | "locale": "@array@", 28 | "inStock": "@array@", 29 | "paginator": "@array@", 30 | "search": { 31 | "state": { 32 | "active": true, 33 | "value": "hut", 34 | "urlParameters": { 35 | "search": "hut" 36 | }, 37 | "name": "search", 38 | "options": [] 39 | }, 40 | "tags": [], 41 | "urlParameters": { 42 | "channel": "WEB_GB", 43 | "enabled": true, 44 | "locale": "de_DE", 45 | "search": "hut" 46 | }, 47 | "resetUrlParameters": { 48 | "channel": "WEB_GB", 49 | "enabled": true, 50 | "locale": "de_DE" 51 | }, 52 | "name": "search" 53 | }, 54 | "attributes": "@array@", 55 | "attributesByCode": "@array@", 56 | "options": "@array@", 57 | "sort": "@array@" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/Responses/Expected/WEB_GB/en_GB/product_list_page_filtered_by_phrase.json: -------------------------------------------------------------------------------- 1 | { 2 | "page": 1, 3 | "limit": 9, 4 | "total": 1, 5 | "pages": 1, 6 | "items": [ 7 | { 8 | "id": 2, 9 | "code": "LOGAN_HAT_CODE", 10 | "name": "Logan Hat", 11 | "slug": "@string@", 12 | "taxons": "@array@", 13 | "variants": "@array@", 14 | "attributes": "@array@", 15 | "images": "@array@", 16 | "price": "@array@", 17 | "channelCode": "WEB_GB", 18 | "localeCode": "en_GB" 19 | } 20 | ], 21 | "filters": { 22 | "channel": "@array@", 23 | "enabled": "@array@", 24 | "taxonCode": "@array@", 25 | "taxonSlug": "@array@", 26 | "priceRange": "@array@", 27 | "locale": "@array@", 28 | "inStock": "@array@", 29 | "paginator": "@array@", 30 | "search": { 31 | "state": { 32 | "active": true, 33 | "value": "hat", 34 | "urlParameters": { 35 | "search": "hat" 36 | }, 37 | "name": "search", 38 | "options": [] 39 | }, 40 | "tags": [], 41 | "urlParameters": { 42 | "channel": "WEB_GB", 43 | "enabled": true, 44 | "locale": "en_GB", 45 | "search": "hat" 46 | }, 47 | "resetUrlParameters": { 48 | "channel": "WEB_GB", 49 | "enabled": true, 50 | "locale": "en_GB" 51 | }, 52 | "name": "search" 53 | }, 54 | "attributes": "@array@", 55 | "attributesByCode": "@array@", 56 | "options": "@array@", 57 | "sort": "@array@" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/Responses/Expected/WEB_GB/en_GB/second_product_list_page_limited_to_two.json: -------------------------------------------------------------------------------- 1 | { 2 | "page": 2, 3 | "limit": 2, 4 | "total": 3, 5 | "pages": 2, 6 | "items": [ 7 | { 8 | "id": 3, 9 | "code": "LOGAN_T_SHIRT_CODE", 10 | "name": "Logan T-Shirt", 11 | "slug": "@string@", 12 | "taxons": "@array@", 13 | "variants": "@array@", 14 | "attributes": "@array@", 15 | "images": "@array@", 16 | "price": "@array@", 17 | "channelCode": "WEB_GB", 18 | "localeCode": "en_GB" 19 | } 20 | ], 21 | "filters": { 22 | "channel": "@array@", 23 | "enabled": "@array@", 24 | "taxonCode": "@array@", 25 | "taxonSlug": "@array@", 26 | "priceRange": "@array@", 27 | "locale": "@array@", 28 | "inStock": "@array@", 29 | "paginator": { 30 | "state": { 31 | "active": true, 32 | "value": 2, 33 | "urlParameters": [], 34 | "name": "paginator", 35 | "options": { 36 | "limit": 2 37 | } 38 | }, 39 | "tags": [], 40 | "urlParameters": { 41 | "channel": "WEB_GB", 42 | "enabled": true, 43 | "locale": "en_GB" 44 | }, 45 | "resetUrlParameters": { 46 | "channel": "WEB_GB", 47 | "enabled": true, 48 | "locale": "en_GB" 49 | }, 50 | "name": "paginator", 51 | "currentPage": 2, 52 | "totalItems": 3, 53 | "maxPages": 20, 54 | "itemsPerPage": 2, 55 | "numPages": 2, 56 | "options": [], 57 | "limit": 2 58 | }, 59 | "search": "@array@", 60 | "attributes": "@array@", 61 | "attributesByCode": "@array@", 62 | "options": "@array@", 63 | "sort": "@array@" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /tests/Application/app/config/config.yml: -------------------------------------------------------------------------------- 1 | parameters: 2 | locale: "en_GB" 3 | secret: "Three can keep a secret, if two of them are dead." 4 | 5 | imports: 6 | - { resource: "@SyliusCoreBundle/Resources/config/app/config.yml" } 7 | - { resource: "@SyliusAdminBundle/Resources/config/app/config.yml" } 8 | - { resource: "@SyliusShopBundle/Resources/config/app/config.yml" } 9 | - { resource: "@SyliusAdminApiBundle/Resources/config/app/config.yml" } 10 | - { resource: "@SyliusElasticSearchPlugin/Resources/config/app/config.yml" } 11 | 12 | - { resource: "security.yml" } 13 | 14 | framework: 15 | translator: { fallbacks: ["%locale%"] } 16 | secret: "%secret" 17 | router: 18 | resource: "%kernel.root_dir%/config/routing.yml" 19 | strict_requirements: "%kernel.debug%" 20 | form: true 21 | csrf_protection: true 22 | validation: { enable_annotations: true } 23 | templating: { engines: ["twig"] } 24 | default_locale: "%locale%" 25 | trusted_proxies: ~ 26 | session: 27 | storage_id: session.storage.mock_file 28 | handler_id: ~ 29 | test: ~ 30 | 31 | doctrine: 32 | dbal: 33 | driver: "pdo_sqlite" 34 | path: "%kernel.root_dir%/../var/db.sql" 35 | charset: UTF8 36 | 37 | fos_rest: 38 | exception: ~ 39 | view: 40 | formats: 41 | json: true 42 | xml: true 43 | empty_content: 204 44 | format_listener: 45 | rules: 46 | - { path: '^/api', priorities: ['json', 'xml'], fallback_format: json, prefer_extension: true } 47 | - { path: '^/', stop: true } 48 | 49 | sylius_resource: 50 | drivers: 51 | - doctrine/orm 52 | 53 | sylius_grid: 54 | drivers: 55 | - doctrine/orm 56 | 57 | ongr_elasticsearch: 58 | managers: 59 | default: 60 | index: 61 | index_name: sylius 62 | mappings: 63 | SyliusElasticSearchPlugin: {} 64 | 65 | sylius_elastic_search: 66 | attribute_whitelist: ['MUG_COLLECTION_CODE', 'MUG_MATERIAL_CODE', 'PRODUCTION_YEAR', 'AVAILABLE_FROM'] 67 | -------------------------------------------------------------------------------- /src/Factory/Document/AttributeDocumentFactory.php: -------------------------------------------------------------------------------- 1 | attributeDocumentClass = $attributeDocumentClass; 20 | } 21 | 22 | public function create( 23 | $data, 24 | LocaleInterface $locale, 25 | ProductAttributeValueInterface $productAttributeValue 26 | ): array { 27 | $productAttributes = []; 28 | 29 | if (is_array($data)) { 30 | foreach ($data as $value) { 31 | $productAttributes = array_merge( 32 | $productAttributes, 33 | $this->create( 34 | $value, 35 | $locale, 36 | $productAttributeValue 37 | ) 38 | ); 39 | } 40 | } else { 41 | /** @var AttributeDocument $productAttribute */ 42 | $productAttribute = new $this->attributeDocumentClass(); 43 | $productAttribute->setCode($productAttributeValue->getCode()); 44 | $productAttribute->setValue($data); 45 | /** @var ProductAttributeTranslationInterface $productAttributeTranslation */ 46 | $productAttributeTranslation = $productAttributeValue->getAttribute()->getTranslation( 47 | $locale->getCode() 48 | ); 49 | $productAttribute->setName($productAttributeTranslation->getName()); 50 | $productAttributes = [$productAttribute]; 51 | } 52 | 53 | return $productAttributes; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "sylius/elastic-search-plugin", 3 | "type": "sylius-bundle", 4 | "description": "Elastic search integration for Sylius.", 5 | "license": "MIT", 6 | "authors": [ 7 | { 8 | "name": "Arkadiusz Krakowiak", 9 | "email": "arkadiusz.k.e@gmail.com", 10 | "homepage": "http://github.com/arminek" 11 | } 12 | ], 13 | "require": { 14 | "php": "^7.1", 15 | "sylius/sylius": "^1.0", 16 | "ongr/elasticsearch-dsl": "^5.0", 17 | "ongr/elasticsearch-bundle": "^5.0", 18 | "simple-bus/symfony-bridge": "^4.1", 19 | "ongr/filter-manager-bundle": "2.1.x-dev as v2.1.1", 20 | "ramsey/uuid": "^3.7" 21 | }, 22 | "require-dev": { 23 | "behat/behat": "^3.2", 24 | "behat/mink": "^1.7", 25 | "behat/mink-browserkit-driver": "^1.3", 26 | "behat/mink-extension": "^2.2", 27 | "friends-of-behat/context-service-extension": "^0.3", 28 | "friends-of-behat/cross-container-extension": "^0.2", 29 | "friends-of-behat/performance-extension": "^1.0", 30 | "friends-of-behat/service-container-extension": "^0.3", 31 | "friends-of-behat/symfony-extension": "^0.2.1", 32 | "friends-of-behat/variadic-extension": "^0.1", 33 | "lakion/mink-debug-extension": "^1.2.3", 34 | "matthiasnoback/symfony-config-test": "^2.1", 35 | "matthiasnoback/symfony-dependency-injection-test": "^1.1", 36 | "php-http/guzzle6-adapter": "^1.1", 37 | "phpspec/phpspec": "^3.2", 38 | "phpunit/phpunit": "^5.6", 39 | "behat/mink-selenium2-driver": "^1.3", 40 | "lakion/api-test-case": "^1.1", 41 | "doctrine/common": "<2.8", 42 | "symplify/easy-coding-standard": "^2.4", 43 | "sylius-labs/coding-standard": "^1.0" 44 | }, 45 | "config": { 46 | "bin-dir": "bin" 47 | }, 48 | "minimum-stability": "stable", 49 | "prefer-stable": true, 50 | "autoload": { 51 | "psr-4": { 52 | "Sylius\\ElasticSearchPlugin\\": "src/", 53 | "Tests\\Sylius\\ElasticSearchPlugin\\": "tests/" 54 | } 55 | }, 56 | "autoload-dev": { 57 | "classmap": ["tests/Application/app/AppKernel.php"] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/Document/AttributeDocument.php: -------------------------------------------------------------------------------- 1 | code; 61 | } 62 | 63 | /** 64 | * @param string $code 65 | */ 66 | public function setCode(string $code): void 67 | { 68 | $this->code = $code; 69 | } 70 | 71 | /** 72 | * @return string 73 | */ 74 | public function getName(): ?string 75 | { 76 | return $this->name; 77 | } 78 | 79 | /** 80 | * @param string $name 81 | */ 82 | public function setName(?string $name): void 83 | { 84 | $this->name = $name; 85 | } 86 | 87 | /** 88 | * @return mixed 89 | */ 90 | public function getValue() 91 | { 92 | return $this->value; 93 | } 94 | 95 | /** 96 | * @param mixed $value 97 | */ 98 | public function setValue($value): void 99 | { 100 | $this->value = $value; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Document/OptionDocument.php: -------------------------------------------------------------------------------- 1 | code; 61 | } 62 | 63 | /** 64 | * @param string $code 65 | */ 66 | public function setCode(string $code): void 67 | { 68 | $this->code = $code; 69 | } 70 | 71 | /** 72 | * @return string 73 | */ 74 | public function getName(): string 75 | { 76 | return $this->name; 77 | } 78 | 79 | /** 80 | * @param string $name 81 | */ 82 | public function setName(string $name): void 83 | { 84 | $this->name = $name; 85 | } 86 | 87 | /** 88 | * @return string 89 | */ 90 | public function getValue(): string 91 | { 92 | return $this->value; 93 | } 94 | 95 | /** 96 | * @param string $value 97 | */ 98 | public function setValue(string $value): void 99 | { 100 | $this->value = $value; 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/Factory/Document/TaxonDocumentFactory.php: -------------------------------------------------------------------------------- 1 | taxonDocumentClass = $taxonDocumentClass; 25 | $this->imageDocumentFactory = $imageDocumentFactory; 26 | } 27 | 28 | /** 29 | * @param TaxonInterface $taxon Sylius taxon model 30 | * @param LocaleInterface $localeCode 31 | * @param int|null $position Override the position in the Taxon model by passing your own 32 | * 33 | * @return TaxonDocument 34 | */ 35 | public function create(TaxonInterface $taxon, LocaleInterface $localeCode, ?int $position = null): TaxonDocument 36 | { 37 | /** @var TaxonTranslationInterface $taxonTranslation */ 38 | $taxonTranslation = $taxon->getTranslation($localeCode->getCode()); 39 | 40 | /** @var TaxonDocument $taxonDocument */ 41 | $taxonDocument = new $this->taxonDocumentClass(); 42 | $taxonDocument->setCode($taxon->getCode()); 43 | $taxonDocument->setSlug($taxonTranslation->getSlug()); 44 | if (is_int($position)) { 45 | $taxonDocument->setPosition($position); 46 | } else { 47 | $taxonDocument->setPosition($taxon->getPosition()); 48 | } 49 | 50 | $taxonDocument->setDescription($taxonTranslation->getDescription()); 51 | 52 | /** @var ImageDocument[] $images */ 53 | $images = []; 54 | foreach ($taxon->getImages() as $image) { 55 | $images[] = $this->imageDocumentFactory->create($image); 56 | } 57 | $taxonDocument->setImages(new Collection($images)); 58 | 59 | return $taxonDocument; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /tests/Responses/Expected/WEB_GB/en_GB/first_product_list_page_limited_to_two.json: -------------------------------------------------------------------------------- 1 | { 2 | "page": 1, 3 | "limit": 2, 4 | "total": 3, 5 | "pages": 2, 6 | "items": [ 7 | { 8 | "id": 1, 9 | "code": "LOGAN_MUG_CODE", 10 | "name": "Logan Mug", 11 | "slug": "@string@", 12 | "taxons": "@array@", 13 | "variants": "@array@", 14 | "attributes": "@array@", 15 | "images": "@array@", 16 | "price": "@array@", 17 | "channelCode": "WEB_GB", 18 | "localeCode": "en_GB" 19 | }, 20 | { 21 | "id": 2, 22 | "code": "LOGAN_HAT_CODE", 23 | "name": "Logan Hat", 24 | "slug": "@string@", 25 | "taxons": "@array@", 26 | "variants": "@array@", 27 | "attributes": "@array@", 28 | "images": "@array@", 29 | "price": "@array@", 30 | "channelCode": "WEB_GB", 31 | "localeCode": "en_GB" 32 | } 33 | ], 34 | "filters": { 35 | "channel": "@array@", 36 | "enabled": "@array@", 37 | "taxonCode": "@array@", 38 | "taxonSlug": "@array@", 39 | "priceRange": "@array@", 40 | "locale": "@array@", 41 | "inStock": "@array@", 42 | "paginator": { 43 | "state": { 44 | "active": true, 45 | "value": 1, 46 | "urlParameters": [], 47 | "name": "paginator", 48 | "options": { 49 | "limit": 2 50 | } 51 | }, 52 | "tags": [], 53 | "urlParameters": { 54 | "channel": "WEB_GB", 55 | "enabled": true, 56 | "locale": "en_GB" 57 | }, 58 | "resetUrlParameters": { 59 | "channel": "WEB_GB", 60 | "enabled": true, 61 | "locale": "en_GB" 62 | }, 63 | "name": "paginator", 64 | "currentPage": 1, 65 | "totalItems": 3, 66 | "maxPages": 20, 67 | "itemsPerPage": 2, 68 | "numPages": 2, 69 | "options": [], 70 | "limit": 2 71 | }, 72 | "search": "@array@", 73 | "attributes": "@array@", 74 | "attributesByCode": "@array@", 75 | "options": "@array@", 76 | "sort": "@array@" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/Resources/config/services/factories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | %sylius_elastic_search.document.product.class% 6 | 7 | 8 | 9 | 10 | 11 | %sylius_elastic_search.attribute_whitelist% 12 | 13 | 14 | 15 | %sylius_elastic_search.document.taxon.class% 16 | 17 | 18 | 19 | 20 | %sylius_elastic_search.document.attribute.class% 21 | 22 | 23 | 24 | %sylius_elastic_search.document.price.class% 25 | 26 | 27 | 28 | %sylius_elastic_search.document.image.class% 29 | 30 | 31 | 32 | %sylius_elastic_search.document.option.class% 33 | 34 | 35 | 36 | %sylius_elastic_search.document.variant.class% 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /spec/Document/ProductDocumentSpec.php: -------------------------------------------------------------------------------- 1 | shouldHaveType(ProductDocument::class); 18 | } 19 | 20 | function it_has_code() 21 | { 22 | $this->setCode('Mug'); 23 | 24 | $this->getCode()->shouldReturn('Mug'); 25 | } 26 | 27 | function it_has_name() 28 | { 29 | $this->setName('Big Mug'); 30 | 31 | $this->getName()->shouldReturn('Big Mug'); 32 | } 33 | 34 | function it_has_channel_code() 35 | { 36 | $this->setChannelCode('WEB'); 37 | 38 | $this->getChannelCode()->shouldReturn('WEB'); 39 | } 40 | 41 | function it_has_locale_code() 42 | { 43 | $this->setLocaleCode('en'); 44 | 45 | $this->getLocaleCode()->shouldReturn('en'); 46 | } 47 | 48 | function it_has_description() 49 | { 50 | $this->setDescription('Lorem ipsum'); 51 | 52 | $this->getDescription()->shouldReturn('Lorem ipsum'); 53 | } 54 | 55 | function it_has_price() 56 | { 57 | $price = new PriceDocument(); 58 | $this->setPrice($price); 59 | 60 | $this->getPrice()->shouldReturn($price); 61 | } 62 | 63 | function it_has_main_taxon() 64 | { 65 | $taxon = new TaxonDocument(); 66 | $this->setMainTaxon($taxon); 67 | 68 | $this->getMainTaxon()->shouldReturn($taxon); 69 | } 70 | 71 | function it_has_taxons() 72 | { 73 | $taxons = new Collection(); 74 | $this->setTaxons($taxons); 75 | 76 | $this->getTaxons()->shouldReturn($taxons); 77 | } 78 | 79 | function it_has_attributes() 80 | { 81 | $attributeValues = new Collection(); 82 | $this->setAttributes($attributeValues); 83 | 84 | $this->getAttributes()->shouldReturn($attributeValues); 85 | } 86 | 87 | function it_has_slug() 88 | { 89 | $this->setSlug('/mug'); 90 | 91 | $this->getSlug()->shouldReturn('/mug'); 92 | } 93 | 94 | function it_has_images() 95 | { 96 | $images = new Collection(); 97 | $this->setImages($images); 98 | 99 | $this->getImages()->shouldReturn($images); 100 | } 101 | 102 | function it_has_average_review_rating() 103 | { 104 | $this->setAverageReviewRating(2.4); 105 | 106 | $this->getAverageReviewRating()->shouldReturn(2.4); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Document/TaxonDocument.php: -------------------------------------------------------------------------------- 1 | images = new Collection(); 53 | } 54 | 55 | /** 56 | * @return string 57 | */ 58 | public function getCode(): string 59 | { 60 | return $this->code; 61 | } 62 | 63 | /** 64 | * @param string $code 65 | */ 66 | public function setCode(string $code): void 67 | { 68 | $this->code = $code; 69 | } 70 | 71 | /** 72 | * @return string 73 | */ 74 | public function getSlug(): string 75 | { 76 | return $this->slug; 77 | } 78 | 79 | /** 80 | * @param string $slug 81 | */ 82 | public function setSlug(string $slug): void 83 | { 84 | $this->slug = $slug; 85 | } 86 | 87 | /** 88 | * @return int 89 | */ 90 | public function getPosition(): int 91 | { 92 | return $this->position; 93 | } 94 | 95 | /** 96 | * @param int $position 97 | */ 98 | public function setPosition(int $position): void 99 | { 100 | $this->position = $position; 101 | } 102 | 103 | /** 104 | * @return Collection 105 | */ 106 | public function getImages(): Collection 107 | { 108 | return $this->images; 109 | } 110 | 111 | /** 112 | * @param Collection $images 113 | */ 114 | public function setImages(Collection $images): void 115 | { 116 | $this->images = $images; 117 | } 118 | 119 | /** 120 | * @return string|null 121 | */ 122 | public function getDescription(): ?string 123 | { 124 | return $this->description; 125 | } 126 | 127 | /** 128 | * @param string|null $description 129 | */ 130 | public function setDescription(?string $description): void 131 | { 132 | $this->description = $description; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Filter/Widget/Pager.php: -------------------------------------------------------------------------------- 1 | getOption('limit', 10); 28 | } 29 | 30 | return (int) $this->getOption('limit', 10); 31 | } 32 | 33 | /** 34 | * {@inheritdoc} 35 | */ 36 | public function getState(Request $request): FilterState 37 | { 38 | $state = parent::getState($request); 39 | // Reset pager with any filter. 40 | $state->setUrlParameters([]); 41 | $page = (int) $state->getValue(); 42 | $state->setValue($page < 1 ? 1 : $page); 43 | $state->addOption('limit', (int) $request->get('limit', (int) $this->getOption('limit', 10))); 44 | 45 | return $state; 46 | } 47 | 48 | /** 49 | * {@inheritdoc} 50 | */ 51 | public function modifySearch(Search $search, ?FilterState $state = null, ?SearchRequest $request = null): void 52 | { 53 | if ($state && $state->isActive()) { 54 | $search->setFrom($this->getCountPerPage($state) * ($state->getValue() - 1)); 55 | } 56 | 57 | $search->setSize($this->getCountPerPage($state)); 58 | } 59 | 60 | /** 61 | * {@inheritdoc} 62 | */ 63 | public function preProcessSearch(Search $search, Search $relatedSearch, ?FilterState $state = null) 64 | { 65 | // Nothing to do here. 66 | } 67 | 68 | /** 69 | * {@inheritdoc} 70 | */ 71 | public function createViewData(): PagerAwareViewData 72 | { 73 | return new PagerAwareViewData(); 74 | } 75 | 76 | /** 77 | * {@inheritdoc} 78 | */ 79 | public function getViewData(DocumentIterator $result, ViewData $data): ViewData 80 | { 81 | /** @var ViewData\PagerAwareViewData $data */ 82 | $data->setData( 83 | $result->count(), 84 | $data->getState()->getValue(), 85 | $data->getState()->getOption('limit'), 86 | $this->getOption('max_pages', 10) 87 | ); 88 | 89 | return $data; 90 | } 91 | 92 | /** 93 | * {@inheritdoc} 94 | */ 95 | public function isRelated(): bool 96 | { 97 | return false; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /tests/Application/app/config/security.yml: -------------------------------------------------------------------------------- 1 | security: 2 | providers: 3 | sylius_admin_user_provider: 4 | id: sylius.admin_user_provider.email_or_name_based 5 | sylius_shop_user_provider: 6 | id: sylius.shop_user_provider.email_or_name_based 7 | encoders: 8 | Sylius\Component\User\Model\UserInterface: sha512 9 | firewalls: 10 | admin: 11 | switch_user: true 12 | context: admin 13 | pattern: /admin(?:/.*)?$ 14 | form_login: 15 | provider: sylius_admin_user_provider 16 | login_path: sylius_admin_login 17 | check_path: sylius_admin_login_check 18 | failure_path: sylius_admin_login 19 | default_target_path: sylius_admin_dashboard 20 | use_forward: false 21 | use_referer: true 22 | logout: 23 | path: sylius_admin_logout 24 | target: sylius_admin_login 25 | anonymous: true 26 | 27 | oauth_token: 28 | pattern: ^/api/oauth/v2/token 29 | security: false 30 | 31 | api: 32 | pattern: ^/api 33 | fos_oauth: true 34 | stateless: true 35 | anonymous: true 36 | 37 | shop: 38 | switch_user: { role: ROLE_ALLOWED_TO_SWITCH } 39 | context: shop 40 | pattern: /.* 41 | form_login: 42 | success_handler: sylius.authentication.success_handler 43 | failure_handler: sylius.authentication.failure_handler 44 | provider: sylius_shop_user_provider 45 | login_path: sylius_shop_login 46 | check_path: sylius_shop_login_check 47 | failure_path: sylius_shop_login 48 | default_target_path: sylius_shop_homepage 49 | use_forward: false 50 | use_referer: true 51 | remember_me: 52 | secret: "%secret%" 53 | name: APP_REMEMBER_ME 54 | lifetime: 31536000 55 | always_remember_me: true 56 | remember_me_parameter: _remember_me 57 | logout: 58 | path: sylius_shop_logout 59 | target: sylius_shop_login 60 | invalidate_session: false 61 | success_handler: sylius.handler.shop_user_logout 62 | anonymous: true 63 | 64 | dev: 65 | pattern: ^/(_(profiler|wdt)|css|images|js)/ 66 | security: false 67 | 68 | access_control: 69 | - { path: "^/_partial.*", ip: 127.0.0.1 } 70 | 71 | - { path: ^/login.*, role: IS_AUTHENTICATED_ANONYMOUSLY } 72 | - { path: ^/register, role: IS_AUTHENTICATED_ANONYMOUSLY } 73 | - { path: ^/verify, role: IS_AUTHENTICATED_ANONYMOUSLY } 74 | - { path: "/account.*", role: ROLE_USER } 75 | 76 | - { path: ^/admin/login, role: IS_AUTHENTICATED_ANONYMOUSLY } 77 | - { path: ^/admin/login-check, role: IS_AUTHENTICATED_ANONYMOUSLY } 78 | - { path: "^/admin.*", role: ROLE_ADMINISTRATION_ACCESS } 79 | 80 | - { path: ^/api, role: ROLE_API_ACCESS } 81 | -------------------------------------------------------------------------------- /tests/DependencyInjection/ConfigurationTest.php: -------------------------------------------------------------------------------- 1 | 34 | */ 35 | final class ConfigurationTest extends \PHPUnit_Framework_TestCase 36 | { 37 | use ConfigurationTestCaseTrait; 38 | 39 | /** 40 | * @test 41 | */ 42 | public function it_has_document_classes() 43 | { 44 | $this->assertProcessedConfigurationEquals([], ['document_classes' => [ 45 | 'product' => ProductDocument::class, 46 | 'attribute' => AttributeDocument::class, 47 | 'image' => ImageDocument::class, 48 | 'price' => PriceDocument::class, 49 | 'taxon' => TaxonDocument::class, 50 | 'variant' => VariantDocument::class, 51 | 'option' => OptionDocument::class, 52 | ]], 'document_classes'); 53 | } 54 | 55 | /** 56 | * @test 57 | */ 58 | public function it_has_view_classes() 59 | { 60 | $this->assertProcessedConfigurationEquals([], ['view_classes' => [ 61 | 'product_list' => ProductListView::class, 62 | 'product' => ProductView::class, 63 | 'product_variant' => VariantView::class, 64 | 'attribute' => AttributeView::class, 65 | 'image' => ImageView::class, 66 | 'price' => PriceView::class, 67 | 'taxon' => TaxonView::class, 68 | ]], 'view_classes'); 69 | } 70 | 71 | /** 72 | * @test 73 | */ 74 | public function it_has_attribute_white_list() 75 | { 76 | $this->assertProcessedConfigurationEquals( 77 | ['sylius_elastic_search' => ['attribute_whitelist' => ['color']]], 78 | ['attribute_whitelist' => ['color']], 79 | 'attribute_whitelist' 80 | ); 81 | } 82 | 83 | /** 84 | * {@inheritdoc} 85 | */ 86 | protected function getConfiguration() 87 | { 88 | return new Configuration(); 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Filter/Widget/MultiDynamicAggregateOverride.php: -------------------------------------------------------------------------------- 1 | ', $this->getDocumentField()); 30 | $filter = !empty($filter = $relatedSearch->getPostFilters()) ? $filter : new MatchAllQuery(); 31 | $aggregation = new NestedAggregation($state->getName(), $path); 32 | $nameAggregation = new TermsAggregation('name', $this->getNameField()); 33 | $valueAggregation = new TermsAggregation('value', $field); 34 | $filterAggregation = new FilterAggregation($state->getName() . '-filter'); 35 | $nameAggregation->addAggregation($valueAggregation); 36 | $aggregation->addAggregation($nameAggregation); 37 | $filterAggregation->setFilter($filter); 38 | 39 | if ($this->hasOption('names_sort_type')) { 40 | $valueAggregation->addParameter('order', [$this->getOption('names_sort_type') => $this->getOption('names_sort_order')]); 41 | } 42 | 43 | if ($this->hasOption('names_size')) { 44 | $nameAggregation->addParameter('size', $this->getOption('names_size')); 45 | } 46 | 47 | if ($this->hasOption('values_sort_type')) { 48 | $valueAggregation->addParameter('order', [$this->getOption('values_sort_type') => $this->getOption('values_sort_order')]); 49 | } 50 | 51 | if ($this->getOption('values_size')) { 52 | $valueAggregation->addParameter('size', $this->getOption('values_size')); 53 | } 54 | 55 | if ($state->isActive()) { 56 | foreach ($state->getValue() as $key => $term) { 57 | $terms = $state->getValue(); 58 | unset($terms[$key]); 59 | 60 | $this->addSubFilterAggregation( 61 | $filterAggregation, 62 | $aggregation, 63 | $terms, 64 | $key 65 | ); 66 | } 67 | } 68 | 69 | $this->addSubFilterAggregation( 70 | $filterAggregation, 71 | $aggregation, 72 | $state->getValue() ? $state->getValue() : [], 73 | 'all-selected' 74 | ); 75 | 76 | $search->addAggregation($filterAggregation); 77 | 78 | if ($this->getShowZeroChoices()) { 79 | $search->addAggregation($aggregation); 80 | } 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/Factory/Document/VariantDocumentFactory.php: -------------------------------------------------------------------------------- 1 | variantDocumentClass = $variantDocumentClass; 37 | $this->priceDocumentFactory = $priceDocumentFactory; 38 | $this->imageDocumentFactory = $imageDocumentFactory; 39 | $this->optionDocumentFactory = $optionDocumentFactory; 40 | } 41 | 42 | public function create( 43 | ProductVariantInterface $productVariant, 44 | ChannelInterface $channel, 45 | LocaleInterface $locale 46 | ): VariantDocument { 47 | $options = []; 48 | foreach ($productVariant->getOptionValues() as $optionValue) { 49 | $options[] = $this->optionDocumentFactory->create($optionValue, $locale); 50 | } 51 | 52 | /** @var ChannelPricingInterface $channelPricing */ 53 | $channelPricing = $productVariant->getChannelPricingForChannel($channel); 54 | 55 | $price = $this->priceDocumentFactory->create( 56 | $channelPricing, 57 | $channel->getBaseCurrency() 58 | ); 59 | 60 | /** @var ProductVariantTranslationInterface $productVariantTranslation */ 61 | $productVariantTranslation = $productVariant->getTranslation($locale->getCode()); 62 | 63 | /** @var VariantDocument $variant */ 64 | $variant = new $this->variantDocumentClass(); 65 | $variant->setId($productVariant->getId()); 66 | $variant->setCode($productVariant->getCode()); 67 | 68 | if (!$productVariantTranslation->getName()) { 69 | $variant->setName($productVariant->getProduct()->getTranslation($locale->getCode())->getName()); 70 | } else { 71 | $variant->setName($productVariantTranslation->getName()); 72 | } 73 | 74 | $variant->setPrice($price); 75 | $variant->setStock($productVariant->getOnHand() - $productVariant->getOnHold()); 76 | $variant->setIsTracked($productVariant->isTracked()); 77 | $variant->setOptions(new Collection($options)); 78 | if ($productVariant->getImages()->count() > 0) { 79 | /** @var ImageDocument[] $images */ 80 | $images = []; 81 | foreach ($productVariant->getImages() as $image) { 82 | $images[] = $this->imageDocumentFactory->create($image); 83 | } 84 | $variant->setImages(new Collection($images)); 85 | } 86 | 87 | return $variant; 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /tests/DependencyInjection/SyliusElasticSearchExtensionTest.php: -------------------------------------------------------------------------------- 1 | load([]); 40 | 41 | $this->assertContainerBuilderHasParameter('sylius_elastic_search.document.product.class', ProductDocument::class); 42 | $this->assertContainerBuilderHasParameter('sylius_elastic_search.document.attribute.class', AttributeDocument::class); 43 | $this->assertContainerBuilderHasParameter('sylius_elastic_search.document.image.class', ImageDocument::class); 44 | $this->assertContainerBuilderHasParameter('sylius_elastic_search.document.price.class', PriceDocument::class); 45 | $this->assertContainerBuilderHasParameter('sylius_elastic_search.document.taxon.class', TaxonDocument::class); 46 | $this->assertContainerBuilderHasParameter('sylius_elastic_search.document.variant.class', VariantDocument::class); 47 | $this->assertContainerBuilderHasParameter('sylius_elastic_search.document.option.class', OptionDocument::class); 48 | } 49 | 50 | /** 51 | * @test 52 | */ 53 | public function it_defines_view_classes_parameters() 54 | { 55 | $this->load([]); 56 | 57 | $this->assertContainerBuilderHasParameter('sylius_elastic_search.view.product_list.class', ProductListView::class); 58 | $this->assertContainerBuilderHasParameter('sylius_elastic_search.view.product.class', ProductView::class); 59 | $this->assertContainerBuilderHasParameter('sylius_elastic_search.view.product_variant.class', VariantView::class); 60 | $this->assertContainerBuilderHasParameter('sylius_elastic_search.view.attribute.class', AttributeView::class); 61 | $this->assertContainerBuilderHasParameter('sylius_elastic_search.view.image.class', ImageView::class); 62 | $this->assertContainerBuilderHasParameter('sylius_elastic_search.view.price.class', PriceView::class); 63 | $this->assertContainerBuilderHasParameter('sylius_elastic_search.view.taxon.class', TaxonView::class); 64 | } 65 | 66 | /** 67 | * @test 68 | */ 69 | public function it_defines_attribute_whitelist_parameter() 70 | { 71 | $this->load([]); 72 | 73 | $this->assertContainerBuilderHasParameter('sylius_elastic_search.attribute_whitelist', []); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/Controller/SearchController.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | final class SearchController 20 | { 21 | /** 22 | * @var ViewHandlerInterface 23 | */ 24 | private $restViewHandler; 25 | 26 | /** 27 | * @var \Sylius\ElasticSearchPlugin\Factory\View\ProductListViewFactoryInterface 28 | */ 29 | private $productListViewFactory; 30 | 31 | /** 32 | * @var FilterManagerInterface 33 | */ 34 | private $filterManager; 35 | 36 | /** 37 | * @var RepositoryInterface 38 | */ 39 | private $channelRepository; 40 | 41 | /** 42 | * @param ViewHandlerInterface $restViewHandler 43 | * @param \Sylius\ElasticSearchPlugin\Factory\View\ProductListViewFactoryInterface $productListViewFactory 44 | * @param FilterManagerInterface $filterManager 45 | * @param RepositoryInterface $channelRepository 46 | */ 47 | public function __construct( 48 | ViewHandlerInterface $restViewHandler, 49 | ProductListViewFactoryInterface $productListViewFactory, 50 | FilterManagerInterface $filterManager, 51 | RepositoryInterface $channelRepository 52 | ) { 53 | $this->restViewHandler = $restViewHandler; 54 | $this->productListViewFactory = $productListViewFactory; 55 | $this->filterManager = $filterManager; 56 | $this->channelRepository = $channelRepository; 57 | } 58 | 59 | /** 60 | * @param Request $request 61 | * 62 | * @return Response 63 | */ 64 | public function __invoke(Request $request) 65 | { 66 | if (!$request->query->has('channel')) { 67 | throw new NotFoundHttpException('Cannot find products without channel provided!'); 68 | } 69 | 70 | if (!$request->query->has('locale')) { 71 | $channelCode = $request->query->get('channel'); 72 | $channel = $this->channelRepository->findOneBy(['code' => $channelCode]); 73 | 74 | if (null === $channel) { 75 | throw new NotFoundHttpException(sprintf('Channel with code "%s" cannot be found!', $channelCode)); 76 | } 77 | 78 | $request->query->set('locale', $channel->getDefaultLocale()->getCode()); 79 | } 80 | 81 | if (!$request->query->has('stock')) { 82 | $request->query->set('stock', '1'); 83 | } 84 | 85 | if (!$request->query->has('sort')) { 86 | if (null !== $request->get('taxonCode')) { 87 | $request->query->set('sort', ['taxonPositionByCode' => [$request->get('taxonCode') => 'asc']]); 88 | } 89 | 90 | if (null !== $request->get('taxonSlug')) { 91 | $request->query->set('sort', ['taxonPositionBySlug' => [$request->get('taxonSlug') => 'asc']]); 92 | } 93 | } 94 | 95 | $request->query->set('enabled', true); 96 | 97 | $response = $this->filterManager->handleRequest($request); 98 | 99 | return $this->restViewHandler->handle( 100 | View::create( 101 | $this->productListViewFactory->createFromSearchResponse($response), 102 | Response::HTTP_OK 103 | ) 104 | ); 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Filter/Widget/InStock.php: -------------------------------------------------------------------------------- 1 | get($this->getRequestField()); 33 | 34 | if (isset($value) && $value !== '') { 35 | $value = is_array($value) ? array_values($value) : $value; 36 | $state->setActive(true); 37 | $state->setValue($value); 38 | } 39 | 40 | if (!$state->isActive()) { 41 | return $state; 42 | } 43 | 44 | $values = explode(';', $state->getValue(), 2); 45 | 46 | $argumentCount = count($values); 47 | if ($argumentCount === 0) { 48 | $state->setActive(false); 49 | 50 | return $state; 51 | } 52 | 53 | $gt = $this->isInclusive() ? 'gte' : 'gt'; 54 | $lt = $this->isInclusive() ? 'lte' : 'lt'; 55 | 56 | $normalized = []; 57 | 58 | if ($argumentCount === 1) { 59 | $normalized[$gt] = $values[0]; 60 | } 61 | if ($argumentCount === 2) { 62 | $normalized[$gt] = $values[0]; 63 | $normalized[$lt] = $values[1]; 64 | } 65 | 66 | $state->setValue($normalized); 67 | 68 | return $state; 69 | } 70 | 71 | /** 72 | * {@inheritdoc} 73 | */ 74 | public function modifySearch(Search $search, FilterState $state = null, SearchRequest $request = null) 75 | { 76 | if ($state && $state->isActive()) { 77 | $boolQuery = new BoolQuery(); 78 | $boolQuery->add(new RangeQuery($this->getDocumentField(), $state->getValue()), BoolQuery::SHOULD); 79 | $boolQuery->add(new TermQuery('variants.is_tracked', false), BoolQuery::SHOULD); 80 | $nestedQuery = new NestedQuery('variants', $boolQuery); 81 | $search->addPostFilter($nestedQuery); 82 | } 83 | } 84 | 85 | /** 86 | * {@inheritdoc} 87 | */ 88 | public function preProcessSearch(Search $search, Search $relatedSearch, FilterState $state = null) 89 | { 90 | $stateAgg = new StatsAggregation($state->getName()); 91 | $stateAgg->setField($this->getDocumentField()); 92 | $filters = $relatedSearch->getPostFilters(); 93 | if (!empty($filters)) { 94 | $search->addPostFilter($filters); 95 | } 96 | $search->addAggregation($stateAgg); 97 | } 98 | 99 | /** 100 | * {@inheritdoc} 101 | */ 102 | public function getViewData(DocumentIterator $result, ViewData $data) 103 | { 104 | $name = $data->getState()->getName(); 105 | /** @var $data ViewData\RangeAwareViewData */ 106 | $data->setMinBounds($result->getAggregation($name)['min']); 107 | $data->setMaxBounds($result->getAggregation($name)['max']); 108 | 109 | return $data; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/Projection/ProductProjector.php: -------------------------------------------------------------------------------- 1 | elasticsearchManager = $elasticsearchManager; 45 | $this->productDocumentRepository = $elasticsearchManager->getRepository(ProductDocument::class); 46 | $this->productDocumentFactory = $productDocumentFactory; 47 | } 48 | 49 | /** 50 | * @param ProductCreated $event 51 | */ 52 | public function handleProductCreated(ProductCreated $event): void 53 | { 54 | $this->scheduleCreatingNewProductDocuments($event->product()); 55 | $this->scheduleRemovingOldProductDocuments($event->product()); 56 | 57 | $this->elasticsearchManager->commit(); 58 | } 59 | 60 | /** 61 | * We create a new product documents with updated data and remove old once 62 | * 63 | * @param ProductUpdated $event 64 | */ 65 | public function handleProductUpdated(ProductUpdated $event): void 66 | { 67 | $product = $event->product(); 68 | 69 | $this->scheduleCreatingNewProductDocuments($product); 70 | $this->scheduleRemovingOldProductDocuments($product); 71 | 72 | $this->elasticsearchManager->commit(); 73 | } 74 | 75 | /** 76 | * We remove deleted product 77 | * 78 | * @param ProductDeleted $event 79 | */ 80 | public function handleProductDeleted(ProductDeleted $event): void 81 | { 82 | $product = $event->product(); 83 | 84 | $this->scheduleRemovingOldProductDocuments($product); 85 | 86 | $this->elasticsearchManager->commit(); 87 | } 88 | 89 | private function scheduleCreatingNewProductDocuments(ProductInterface $product): void 90 | { 91 | /** @var ChannelInterface[] $channels */ 92 | $channels = $product->getChannels(); 93 | foreach ($channels as $channel) { 94 | /** @var LocaleInterface[] $locales */ 95 | $locales = $channel->getLocales(); 96 | foreach ($locales as $locale) { 97 | $this->elasticsearchManager->persist( 98 | $this->productDocumentFactory->create( 99 | $product, 100 | $locale, 101 | $channel 102 | ) 103 | ); 104 | } 105 | } 106 | } 107 | 108 | private function scheduleRemovingOldProductDocuments(ProductInterface $product): void 109 | { 110 | /** @var DocumentIterator|ProductDocument[] $currentProductDocuments */ 111 | $currentProductDocuments = $this->productDocumentRepository->findBy(['code' => $product->getCode()]); 112 | 113 | foreach ($currentProductDocuments as $sameCodeProductDocument) { 114 | $this->elasticsearchManager->remove($sameCodeProductDocument); 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /tests/Responses/Expected/WEB_GB/en_GB/product_list_page_filtered_by_price_range.json: -------------------------------------------------------------------------------- 1 | { 2 | "page": 1, 3 | "limit": 9, 4 | "total": 2, 5 | "pages": 1, 6 | "items": [ 7 | { 8 | "id": 1, 9 | "code": "LOGAN_MUG_CODE", 10 | "name": "Logan Mug", 11 | "slug": "@string@", 12 | "taxons": "@array@", 13 | "variants": [ 14 | { 15 | "id": 2, 16 | "code": "LOGAN_MUG_SMALL_CODE", 17 | "name": "Gnome Mug", 18 | "price": { 19 | "current": 1000, 20 | "currency": "GBP", 21 | "original": 0 22 | }, 23 | "stock": 0, 24 | "isTracked": true, 25 | "images": [] 26 | }, 27 | { 28 | "id": 1, 29 | "code": "LOGAN_MUG_BIG_CODE", 30 | "name": "Logan Mug", 31 | "price": { 32 | "current": 1000, 33 | "currency": "GBP", 34 | "original": 0 35 | }, 36 | "stock": 0, 37 | "isTracked": false, 38 | "images": [] 39 | } 40 | ], 41 | "attributes": "@array@", 42 | "images": "@array@", 43 | "price": { 44 | "current": 1000, 45 | "currency": "GBP", 46 | "original": 0 47 | }, 48 | "channelCode": "WEB_GB", 49 | "localeCode": "en_GB" 50 | }, 51 | { 52 | "id": 2, 53 | "code": "LOGAN_HAT_CODE", 54 | "name": "Logan Hat", 55 | "slug": "@string@", 56 | "taxons": "@array@", 57 | "variants": [ 58 | { 59 | "id": 3, 60 | "code": "LOGAN_HAT_CODE", 61 | "name": "Logan Hat", 62 | "price": { 63 | "current": 1500, 64 | "currency": "GBP", 65 | "original": 0 66 | }, 67 | "stock": 2, 68 | "isTracked": true, 69 | "images": [] 70 | } 71 | ], 72 | "attributes": "@array@", 73 | "images": "@array@", 74 | "price": { 75 | "current": 1500, 76 | "currency": "GBP", 77 | "original": 0 78 | }, 79 | "channelCode": "WEB_GB", 80 | "localeCode": "en_GB" 81 | } 82 | ], 83 | "filters": { 84 | "channel": "@array@", 85 | "enabled": "@array@", 86 | "taxonCode": "@array@", 87 | "taxonSlug": "@array@", 88 | "priceRange": { 89 | "state": { 90 | "active": true, 91 | "value": { 92 | "gte": "1000", 93 | "lte": "1500" 94 | }, 95 | "urlParameters": { 96 | "price": "1000;1500" 97 | }, 98 | "name": "priceRange", 99 | "options": [] 100 | }, 101 | "tags": [], 102 | "urlParameters": { 103 | "channel": "WEB_GB", 104 | "enabled": true, 105 | "price": "1000;1500", 106 | "locale": "en_GB" 107 | }, 108 | "resetUrlParameters": { 109 | "channel": "WEB_GB", 110 | "enabled": true, 111 | "locale": "en_GB" 112 | }, 113 | "name": "priceRange", 114 | "minBounds": 1000, 115 | "maxBounds": 2000 116 | }, 117 | "locale": "@array@", 118 | "inStock": "@array@", 119 | "paginator": "@array@", 120 | "search": "@array@", 121 | "attributes": "@array@", 122 | "attributesByCode": "@array@", 123 | "options": "@array@", 124 | "sort": "@array@" 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/Command/ResetProductIndexCommand.php: -------------------------------------------------------------------------------- 1 | productRepository = $productRepository; 45 | $this->elasticsearchManager = $manager; 46 | $this->productDocumentFactory = $productDocumentFactory; 47 | 48 | parent::__construct('sylius:elastic-search:reset-product-index'); 49 | } 50 | 51 | /** 52 | * {@inheritdoc} 53 | */ 54 | protected function configure(): void 55 | { 56 | $this 57 | ->addOption('force', 'f', null, 'To confirm running this command') 58 | ; 59 | } 60 | 61 | /** 62 | * {@inheritdoc} 63 | */ 64 | protected function execute(InputInterface $input, OutputInterface $output) 65 | { 66 | $lockHandler = new LockHandler('sylius-elastic-index-update'); 67 | if ($lockHandler->lock()) { 68 | if (!$input->getOption('force')) { 69 | $output->writeln('WARNING! This command will drop the existing index and rebuild it from scratch. To proceed, run with "--force" option.'); 70 | 71 | return; 72 | } 73 | 74 | $output->writeln(sprintf('Dropping and creating "%s" ElasticSearch index', $this->elasticsearchManager->getIndexName())); 75 | $this->elasticsearchManager->dropAndCreateIndex(); 76 | 77 | $productDocumentsCreated = 0; 78 | 79 | /** @var ProductInterface[] $products */ 80 | $products = $this->productRepository->findAll(); 81 | 82 | $output->writeln(sprintf('Loading %d products into ElasticSearch', count($products))); 83 | 84 | foreach ($products as $product) { 85 | $channels = $product->getChannels(); 86 | 87 | /** @var ChannelInterface $channel */ 88 | foreach ($channels as $channel) { 89 | $locales = $channel->getLocales(); 90 | foreach ($locales as $locale) { 91 | $productDocument = $this->productDocumentFactory->create( 92 | $product, 93 | $locale, 94 | $channel 95 | ); 96 | 97 | $this->elasticsearchManager->persist($productDocument); 98 | 99 | ++$productDocumentsCreated; 100 | if (($productDocumentsCreated % 100) === 0) { 101 | $this->elasticsearchManager->commit(); 102 | } 103 | } 104 | } 105 | } 106 | 107 | $this->elasticsearchManager->commit(); 108 | $lockHandler->release(); 109 | $output->writeln('Product index was rebuilt!'); 110 | } else { 111 | $output->writeln(sprintf('Command is already running')); 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/Resources/config/app/config.yml: -------------------------------------------------------------------------------- 1 | ongr_elasticsearch: 2 | analysis: 3 | analyzer: 4 | keywordAnalyzer: 5 | type: custom 6 | tokenizer: keyword 7 | filter: [lowercase] 8 | incrementalAnalyzer: 9 | type: custom 10 | tokenizer: standard 11 | filter: 12 | - lowercase 13 | - asciifolding 14 | 15 | ongr_filter_manager: 16 | managers: 17 | search_list: 18 | filters: 19 | - channel 20 | - enabled 21 | - taxonCode 22 | - taxonSlug 23 | - priceRange 24 | - inStock 25 | - locale 26 | - paginator 27 | - search 28 | - attributes 29 | - attributesByCode 30 | - options 31 | - sort 32 | repository: es.manager.default.product 33 | filters: 34 | channel: 35 | type: choice 36 | request_field: channel 37 | document_field: channel_code 38 | enabled: 39 | type: choice 40 | request_field: enabled 41 | document_field: enabled 42 | taxonCode: 43 | type: sylius_elastic_search.choice_nested 44 | request_field: taxonCode 45 | document_field: taxons>taxons.code 46 | taxonSlug: 47 | type: sylius_elastic_search.choice_nested 48 | request_field: taxonSlug 49 | document_field: taxons>taxons.slug 50 | priceRange: 51 | type: range 52 | request_field: price 53 | document_field: price.amount 54 | options: 55 | inclusive: true 56 | inStock: 57 | type: sylius_elastic_search.in_stock 58 | request_field: stock 59 | document_field: variants.stock 60 | options: 61 | inclusive: true 62 | locale: 63 | type: choice 64 | request_field: locale 65 | document_field: locale_code 66 | paginator: 67 | type: sylius_elastic_search.custom_pager 68 | document_field: ~ 69 | request_field: page 70 | options: 71 | limit: 9 72 | max_pages: 20 73 | attributes: 74 | type: sylius_elastic_search.multi_dynamic_aggregate 75 | request_field: attributes 76 | document_field: attributes>attributes.value.raw 77 | options: 78 | name_field: attributes.name.raw 79 | names_sort_type: _term 80 | names_sort_order: asc 81 | names_size: 100 82 | values_sort_type: _count 83 | values_sort_order: desc 84 | values_size: 100 85 | attributesByCode: 86 | type: sylius_elastic_search.multi_dynamic_aggregate_without_view 87 | request_field: attributesByCode 88 | document_field: attributes>attributes.value.raw 89 | options: 90 | name_field: attributes.code 91 | options: 92 | type: sylius_elastic_search.multi_dynamic_aggregate_options 93 | request_field: options 94 | document_field: variants>variants.options>variants.options.value.raw 95 | options: 96 | name_field: variants.options.name.raw 97 | sort_type: _term 98 | sort_order: asc 99 | size: 100 100 | search: 101 | type: match 102 | request_field: search 103 | document_field: name^3,description,attributes>attributes.name.standard,attributes>attributes.value.standard 104 | options: 105 | operator: and 106 | sort: 107 | type: sylius_elastic_search.sort 108 | request_field: sort 109 | document_field: ~ 110 | options: 111 | aliases: 112 | name: name 113 | slug: slug 114 | code: code 115 | price: price.amount 116 | date: created_at 117 | -------------------------------------------------------------------------------- /src/Resources/config/services.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | %sylius_elastic_search.view.product_list.class% 28 | %sylius_elastic_search.view.product.class% 29 | %sylius_elastic_search.view.product_variant.class% 30 | %sylius_elastic_search.view.attribute.class% 31 | %sylius_elastic_search.view.image.class% 32 | %sylius_elastic_search.view.price.class% 33 | %sylius_elastic_search.view.taxon.class% 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/Filter/Widget/Sort.php: -------------------------------------------------------------------------------- 1 | get($this->getRequestField()); 28 | 29 | if (null !== $value && '' !== $value) { 30 | $state->setActive(true); 31 | $state->setValue($value); 32 | $state->setUrlParameters([$this->getRequestField() => $value]); 33 | } 34 | 35 | return $state; 36 | } 37 | 38 | /** 39 | * {@inheritdoc} 40 | */ 41 | public function modifySearch(Search $search, ?FilterState $state = null, ?SearchRequest $request = null) 42 | { 43 | if ($state && $state->isActive()) { 44 | $stateValue = $state->getValue(); 45 | 46 | if (!is_array($stateValue)) { 47 | return; 48 | } 49 | 50 | $aliases = $this->getOption('aliases') ?? []; 51 | 52 | foreach ($stateValue as $field => $data) { 53 | if ('attributes' === $field) { 54 | $this->addAttributeFieldToSort($search, $data); 55 | 56 | continue; 57 | } 58 | 59 | if ('taxonPositionByCode' === $field) { 60 | $this->addPositionFieldToSort($search, 'code', $data); 61 | 62 | continue; 63 | } 64 | 65 | if ('taxonPositionBySlug' === $field) { 66 | $this->addPositionFieldToSort($search, 'slug', $data); 67 | 68 | continue; 69 | } 70 | 71 | if (array_key_exists($field, $aliases)) { 72 | $this->addRegularFieldToSort($search, $aliases[$field], $data); 73 | 74 | continue; 75 | } 76 | } 77 | } 78 | } 79 | 80 | /** 81 | * {@inheritdoc} 82 | */ 83 | public function preProcessSearch(Search $search, Search $relatedSearch, ?FilterState $state = null) 84 | { 85 | // Nothing to do here. 86 | } 87 | 88 | /** 89 | * {@inheritdoc} 90 | */ 91 | public function createViewData(): EmptyViewData 92 | { 93 | return new EmptyViewData(); 94 | } 95 | 96 | /** 97 | * {@inheritdoc} 98 | */ 99 | public function getViewData(DocumentIterator $result, ViewData $data): ViewData 100 | { 101 | return $data; 102 | } 103 | 104 | private function addAttributeFieldToSort(Search $search, array $settings): void 105 | { 106 | foreach ($settings as $attributeCode => $sortingOrder) { 107 | $fieldSort = new FieldSort('attributes.value.raw', $sortingOrder, ['nested_path' => 'attributes', 'mode' => 'max']); 108 | $fieldSort->setNestedFilter(new TermQuery('attributes.code', $attributeCode)); 109 | 110 | $search->addSort($fieldSort); 111 | } 112 | } 113 | 114 | private function addPositionFieldToSort(Search $search, string $identifier, array $settings): void 115 | { 116 | foreach ($settings as $taxonIdentifier => $sortingOrder) { 117 | $fieldSort = new FieldSort('taxons.position', $sortingOrder, ['nested_path' => 'taxons']); 118 | $fieldSort->setNestedFilter(new TermQuery(sprintf('taxons.%s', $identifier), $taxonIdentifier)); 119 | 120 | $search->addSort($fieldSort); 121 | } 122 | } 123 | 124 | private function addRegularFieldToSort(Search $search, string $field, string $order, array $options = []): void 125 | { 126 | $search->addSort(new FieldSort($field, $order, $options)); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/Document/VariantDocument.php: -------------------------------------------------------------------------------- 1 | images = new Collection(); 74 | $this->options = new Collection(); 75 | } 76 | 77 | /** 78 | * @return mixed 79 | */ 80 | public function getId() 81 | { 82 | return $this->id; 83 | } 84 | 85 | /** 86 | * @param mixed $id 87 | */ 88 | public function setId($id): void 89 | { 90 | $this->id = $id; 91 | } 92 | 93 | /** 94 | * @return Collection 95 | */ 96 | public function getImages(): Collection 97 | { 98 | return $this->images; 99 | } 100 | 101 | /** 102 | * @param Collection $images 103 | */ 104 | public function setImages(Collection $images): void 105 | { 106 | $this->images = $images; 107 | } 108 | 109 | /** 110 | * @return PriceDocument 111 | */ 112 | public function getPrice(): PriceDocument 113 | { 114 | return $this->price; 115 | } 116 | 117 | /** 118 | * @param PriceDocument $price 119 | */ 120 | public function setPrice(PriceDocument $price): void 121 | { 122 | $this->price = $price; 123 | } 124 | 125 | /** 126 | * @return string 127 | */ 128 | public function getCode(): string 129 | { 130 | return $this->code; 131 | } 132 | 133 | /** 134 | * @param string $code 135 | */ 136 | public function setCode(string $code): void 137 | { 138 | $this->code = $code; 139 | } 140 | 141 | /** 142 | * @return string 143 | */ 144 | public function getName(): string 145 | { 146 | return $this->name; 147 | } 148 | 149 | /** 150 | * @param string $name 151 | */ 152 | public function setName(string $name): void 153 | { 154 | $this->name = $name; 155 | } 156 | 157 | /** 158 | * @return int 159 | */ 160 | public function getStock(): int 161 | { 162 | return $this->stock; 163 | } 164 | 165 | /** 166 | * @param int $stock 167 | */ 168 | public function setStock(int $stock): void 169 | { 170 | $this->stock = $stock; 171 | } 172 | 173 | public function getIsTracked(): bool 174 | { 175 | return $this->isTracked; 176 | } 177 | 178 | /** 179 | * @return bool 180 | */ 181 | public function isTracked(): bool 182 | { 183 | return $this->getIsTracked(); 184 | } 185 | 186 | /** 187 | * @param bool $isTracked 188 | */ 189 | public function setIsTracked(bool $isTracked): void 190 | { 191 | $this->isTracked = $isTracked; 192 | } 193 | 194 | /** 195 | * @return Collection 196 | */ 197 | public function getOptions(): Collection 198 | { 199 | return $this->options; 200 | } 201 | 202 | /** 203 | * @param Collection $options 204 | */ 205 | public function setOptions(Collection $options): void 206 | { 207 | $this->options = $options; 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /spec/Projection/ProductProjectorSpec.php: -------------------------------------------------------------------------------- 1 | getRepository(ProductDocument::class)->willReturn($productDocumentRepository); 28 | 29 | $this->beConstructedWith($elasticsearchManager, $productDocumentFactory); 30 | } 31 | 32 | function it_is_initializable() 33 | { 34 | $this->shouldHaveType(ProductProjector::class); 35 | } 36 | 37 | function it_saves_product_documents_and_removes_the_current_ones( 38 | Manager $elasticsearchManager, 39 | ProductDocumentFactoryInterface $productDocumentFactory, 40 | Repository $productDocumentRepository, 41 | ProductInterface $product, 42 | LocaleInterface $locale, 43 | ChannelInterface $channel 44 | ) { 45 | $product->getCode()->willReturn('FOO'); 46 | $product->getChannels()->willReturn(new ArrayCollection([$channel->getWrappedObject()])); 47 | $channel->getLocales()->willReturn(new ArrayCollection([$locale->getWrappedObject()])); 48 | 49 | $existingProductDocument = new ProductDocument(); 50 | $productDocumentRepository->findBy(['code' => 'FOO'])->willReturn(new \ArrayIterator([$existingProductDocument])); 51 | 52 | $newProductDocument = new ProductDocument(); 53 | $productDocumentFactory->create($product, $locale, $channel)->willReturn($newProductDocument); 54 | 55 | $elasticsearchManager->persist($newProductDocument)->shouldBeCalled(); 56 | $elasticsearchManager->remove($existingProductDocument)->shouldBeCalled(); 57 | $elasticsearchManager->commit()->shouldBeCalled(); 58 | 59 | $this->handleProductCreated(ProductCreated::occur($product->getWrappedObject())); 60 | } 61 | 62 | function it_does_not_save_product_document_if_product_does_not_have_channel_defined( 63 | Manager $elasticsearchManager, 64 | ProductDocumentFactoryInterface $productDocumentFactory, 65 | Repository $productDocumentRepository, 66 | ProductInterface $product 67 | ) { 68 | $product->getCode()->willReturn('FOO'); 69 | $product->getChannels()->willReturn(new ArrayCollection([])); 70 | 71 | $productDocumentRepository->findBy(['code' => 'FOO'])->willReturn(new \ArrayIterator([])); 72 | 73 | $productDocumentFactory->create(Argument::any(), Argument::any(), Argument::any())->shouldNotBeCalled(); 74 | 75 | $elasticsearchManager->persist(Argument::any())->shouldNotBeCalled(); 76 | $elasticsearchManager->commit()->shouldBeCalled(); 77 | 78 | $this->handleProductCreated(ProductCreated::occur($product->getWrappedObject())); 79 | } 80 | 81 | function it_does_not_save_product_document_if_channel_has_not_locales_defined( 82 | Manager $elasticsearchManager, 83 | ProductDocumentFactoryInterface $productDocumentFactory, 84 | Repository $productDocumentRepository, 85 | ProductInterface $product, 86 | ChannelInterface $channel 87 | ) { 88 | $product->getCode()->willReturn('FOO'); 89 | $channel->getLocales()->willReturn(new ArrayCollection([])); 90 | $product->getChannels()->willReturn(new ArrayCollection([$channel->getWrappedObject()])); 91 | 92 | $productDocumentRepository->findBy(['code' => 'FOO'])->willReturn(new \ArrayIterator([])); 93 | 94 | $productDocumentFactory->create(Argument::any(), Argument::any(), Argument::any())->shouldNotBeCalled(); 95 | 96 | $elasticsearchManager->persist(Argument::any())->shouldNotBeCalled(); 97 | $elasticsearchManager->commit()->shouldBeCalled(); 98 | 99 | $this->handleProductCreated(ProductCreated::occur($product->getWrappedObject())); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/DependencyInjection/Configuration.php: -------------------------------------------------------------------------------- 1 | 36 | */ 37 | final class Configuration implements ConfigurationInterface 38 | { 39 | /** 40 | * {@inheritdoc} 41 | */ 42 | public function getConfigTreeBuilder() 43 | { 44 | $treeBuilder = new TreeBuilder(); 45 | $rootNode = $treeBuilder->root('sylius_elastic_search'); 46 | 47 | $this->buildAttributeWhitelistNode($rootNode); 48 | $this->buildDocumentClassesNode($rootNode); 49 | $this->buildViewClassesNode($rootNode); 50 | 51 | return $treeBuilder; 52 | } 53 | 54 | /** 55 | * @param ArrayNodeDefinition $rootNode 56 | */ 57 | private function buildDocumentClassesNode(ArrayNodeDefinition $rootNode) 58 | { 59 | $rootNode 60 | ->addDefaultsIfNotSet() 61 | ->children() 62 | ->arrayNode('document_classes') 63 | ->addDefaultsIfNotSet() 64 | ->children() 65 | ->scalarNode('product')->defaultValue(ProductDocument::class)->end() 66 | ->scalarNode('attribute')->defaultValue(AttributeDocument::class)->end() 67 | ->scalarNode('image')->defaultValue(ImageDocument::class)->end() 68 | ->scalarNode('price')->defaultValue(PriceDocument::class)->end() 69 | ->scalarNode('taxon')->defaultValue(TaxonDocument::class)->end() 70 | ->scalarNode('variant')->defaultValue(VariantDocument::class)->end() 71 | ->scalarNode('option')->defaultValue(OptionDocument::class)->end() 72 | ->end() 73 | ->end() 74 | ->end() 75 | ; 76 | } 77 | 78 | /** 79 | * @param ArrayNodeDefinition $rootNode 80 | */ 81 | private function buildViewClassesNode(ArrayNodeDefinition $rootNode) 82 | { 83 | $rootNode 84 | ->addDefaultsIfNotSet() 85 | ->children() 86 | ->arrayNode('view_classes') 87 | ->addDefaultsIfNotSet() 88 | ->children() 89 | ->scalarNode('product_list')->defaultValue(ProductListView::class)->end() 90 | ->scalarNode('product')->defaultValue(ProductView::class)->end() 91 | ->scalarNode('product_variant')->defaultValue(VariantView::class)->end() 92 | ->scalarNode('attribute')->defaultValue(AttributeView::class)->end() 93 | ->scalarNode('image')->defaultValue(ImageView::class)->end() 94 | ->scalarNode('price')->defaultValue(PriceView::class)->end() 95 | ->scalarNode('taxon')->defaultValue(TaxonView::class)->end() 96 | ->end() 97 | ->end() 98 | ->end() 99 | ; 100 | } 101 | 102 | /** 103 | * @param ArrayNodeDefinition $rootNode 104 | */ 105 | private function buildAttributeWhitelistNode(ArrayNodeDefinition $rootNode) 106 | { 107 | $rootNode 108 | ->addDefaultsIfNotSet() 109 | ->children() 110 | ->arrayNode('attribute_whitelist') 111 | ->prototype('scalar')->end() 112 | ->end() 113 | ->end() 114 | ; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Command/UpdateProductIndexCommand.php: -------------------------------------------------------------------------------- 1 | productRepository = $productRepository; 55 | $this->elasticsearchManager = $elasticsearchManager; 56 | $this->productDocumentRepository = $elasticsearchManager->getRepository(ProductDocument::class); 57 | $this->productDocumentFactory = $productDocumentFactory; 58 | 59 | parent::__construct('sylius:elastic-search:update-product-index'); 60 | } 61 | 62 | /** 63 | * {@inheritdoc} 64 | */ 65 | protected function configure(): void 66 | { 67 | $this->setDescription('Update products in Elasticsearch index.'); 68 | } 69 | 70 | /** 71 | * {@inheritdoc} 72 | */ 73 | protected function execute(InputInterface $input, OutputInterface $output): void 74 | { 75 | $lockHandler = new LockHandler('sylius-elastic-index-update'); 76 | if ($lockHandler->lock()) { 77 | $processedProductsCodes = []; 78 | $productDocumentsWaitingForCommit = 0; 79 | 80 | $search = $this->productDocumentRepository->createSearch(); 81 | $search->setScroll('10m'); 82 | $search->addSort(new FieldSort('synchronised_at', 'asc')); 83 | 84 | /** @var DocumentIterator|ProductDocument[] $productDocuments */ 85 | $productDocuments = $this->productDocumentRepository->findDocuments($search); 86 | 87 | foreach ($productDocuments as $productDocument) { 88 | $productCode = $productDocument->getCode(); 89 | 90 | if (isset($processedProductsCodes[$productCode])) { 91 | continue; 92 | } 93 | 94 | $output->writeln(sprintf('Updating product with code "%s"', $productCode)); 95 | 96 | $this->scheduleCreatingNewProductDocuments($productCode); 97 | $this->scheduleRemovingOldProductDocuments($productCode); 98 | 99 | ++$productDocumentsWaitingForCommit; 100 | if (($productDocumentsWaitingForCommit % 100) === 0) { 101 | $this->elasticsearchManager->commit(); 102 | $productDocumentsWaitingForCommit = 0; 103 | } 104 | 105 | $processedProductsCodes[$productCode] = 1; 106 | } 107 | 108 | if ($productDocumentsWaitingForCommit > 0) { 109 | $this->elasticsearchManager->commit(); 110 | } 111 | $lockHandler->release(); 112 | $output->writeln('Updates done'); 113 | } else { 114 | $output->writeln(sprintf('Command is already running')); 115 | } 116 | } 117 | 118 | private function scheduleCreatingNewProductDocuments(string $productCode): void 119 | { 120 | /** @var ProductInterface|null $product */ 121 | $product = $this->productRepository->findOneBy(['code' => $productCode]); 122 | 123 | if (null === $product) { 124 | return; 125 | } 126 | 127 | /** @var ChannelInterface[] $channels */ 128 | $channels = $product->getChannels(); 129 | 130 | foreach ($channels as $channel) { 131 | /** @var LocaleInterface[] $locales */ 132 | $locales = $channel->getLocales(); 133 | 134 | foreach ($locales as $locale) { 135 | $this->elasticsearchManager->persist($this->productDocumentFactory->create( 136 | $product, 137 | $locale, 138 | $channel 139 | )); 140 | } 141 | } 142 | } 143 | 144 | private function scheduleRemovingOldProductDocuments(string $productCode): void 145 | { 146 | /** @var DocumentIterator|ProductDocument[] $currentProductDocuments */ 147 | $currentProductDocuments = $this->productDocumentRepository->findBy(['code' => $productCode]); 148 | 149 | foreach ($currentProductDocuments as $sameCodeProductDocument) { 150 | $this->elasticsearchManager->remove($sameCodeProductDocument); 151 | } 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/EventListener/ProductPublisher.php: -------------------------------------------------------------------------------- 1 | eventBus = $eventBus; 50 | } 51 | 52 | /** 53 | * @param OnFlushEventArgs $event 54 | */ 55 | public function onFlush(OnFlushEventArgs $event): void 56 | { 57 | $scheduledInsertions = $event->getEntityManager()->getUnitOfWork()->getScheduledEntityInsertions(); 58 | 59 | foreach ($scheduledInsertions as $entity) { 60 | if ($entity instanceof ProductInterface && !isset($this->scheduledInsertions[$entity->getCode()])) { 61 | $this->scheduledInsertions[$entity->getCode()] = $entity; 62 | 63 | continue; 64 | } 65 | 66 | $entity = $this->getProductFromEntity($entity); 67 | if ($entity instanceof ProductInterface && !isset($this->scheduledUpdates[$entity->getCode()])) { 68 | $this->scheduledUpdates[$entity->getCode()] = $entity; 69 | } 70 | } 71 | 72 | $scheduledUpdates = $event->getEntityManager()->getUnitOfWork()->getScheduledEntityUpdates(); 73 | foreach ($scheduledUpdates as $entity) { 74 | $entity = $this->getProductFromEntity($entity); 75 | if ($entity instanceof ProductInterface && !isset($this->scheduledUpdates[$entity->getCode()])) { 76 | $this->scheduledUpdates[$entity->getCode()] = $entity; 77 | } 78 | } 79 | 80 | $scheduledDeletions = $event->getEntityManager()->getUnitOfWork()->getScheduledEntityDeletions(); 81 | foreach ($scheduledDeletions as $entity) { 82 | if ($entity instanceof ProductInterface && !isset($this->scheduledDeletions[$entity->getCode()])) { 83 | $this->scheduledDeletions[$entity->getCode()] = $entity; 84 | 85 | continue; 86 | } 87 | 88 | $entity = $this->getProductFromEntity($entity); 89 | if ($entity instanceof ProductInterface && !isset($this->scheduledUpdates[$entity->getCode()])) { 90 | $this->scheduledUpdates[$entity->getCode()] = $entity; 91 | } 92 | } 93 | } 94 | 95 | /** 96 | * @param PostFlushEventArgs $event 97 | */ 98 | public function postFlush(PostFlushEventArgs $event): void 99 | { 100 | foreach ($this->scheduledInsertions as $product) { 101 | $this->eventBus->handle(ProductCreated::occur($product)); 102 | } 103 | 104 | $scheduledUpdates = array_diff_key( 105 | $this->scheduledUpdates, 106 | $this->scheduledInsertions, 107 | $this->scheduledDeletions 108 | ); 109 | foreach ($scheduledUpdates as $product) { 110 | $this->eventBus->handle(ProductUpdated::occur($product)); 111 | } 112 | 113 | foreach ($this->scheduledDeletions as $product) { 114 | $this->eventBus->handle(ProductDeleted::occur($product)); 115 | } 116 | 117 | $this->scheduledInsertions = []; 118 | $this->scheduledUpdates = []; 119 | $this->scheduledDeletions = []; 120 | } 121 | 122 | /** 123 | * @param object $entity 124 | * 125 | * @return ProductInterface|null 126 | */ 127 | private function getProductFromEntity($entity): ?ProductInterface 128 | { 129 | if ($entity instanceof ProductInterface) { 130 | return $entity; 131 | } 132 | 133 | if ($entity instanceof ProductTranslationInterface) { 134 | return $this->getProductFromEntity($entity->getTranslatable()); 135 | } 136 | 137 | if ($entity instanceof ProductVariantInterface) { 138 | return $entity->getProduct(); 139 | } 140 | 141 | if ($entity instanceof ProductVariantTranslationInterface) { 142 | return $this->getProductFromEntity($entity->getTranslatable()); 143 | } 144 | 145 | if ($entity instanceof ChannelPricingInterface) { 146 | return $this->getProductFromEntity($entity->getProductVariant()); 147 | } 148 | 149 | if ($entity instanceof ProductTaxonInterface) { 150 | return $entity->getProduct(); 151 | } 152 | 153 | if ($entity instanceof ProductAttributeValueInterface) { 154 | return $entity->getProduct(); 155 | } 156 | 157 | if ($entity instanceof ProductImageInterface) { 158 | return $this->getProductFromEntity($entity->getOwner()); 159 | } 160 | 161 | return null; 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /tests/Responses/Expected/WEB_GB/en_GB/product_list_page_filtered_by_mug_material_wood_attribute.json: -------------------------------------------------------------------------------- 1 | { 2 | "page": 1, 3 | "limit": 9, 4 | "total": 1, 5 | "pages": 1, 6 | "items": [ 7 | { 8 | "id": 1, 9 | "code": "LOGAN_MUG_CODE", 10 | "name": "Logan Mug", 11 | "slug": "@string@", 12 | "taxons": "@array@", 13 | "variants": "@array@", 14 | "attributes": "@array@", 15 | "images": "@array@", 16 | "price": "@array@", 17 | "channelCode": "WEB_GB", 18 | "localeCode": "en_GB" 19 | } 20 | ], 21 | "filters": { 22 | "channel": "@array@", 23 | "enabled": "@array@", 24 | "taxonCode": "@array@", 25 | "taxonSlug": "@array@", 26 | "priceRange": "@array@", 27 | "locale": "@array@", 28 | "inStock": "@array@", 29 | "paginator": "@array@", 30 | "search": "@array@", 31 | "attributes": { 32 | "state": { 33 | "active": true, 34 | "value": { 35 | "Mug material": [ 36 | "Wood" 37 | ] 38 | }, 39 | "urlParameters": { 40 | "attributes": { 41 | "Mug material": [ 42 | "Wood" 43 | ] 44 | } 45 | }, 46 | "name": "attributes", 47 | "options": [] 48 | }, 49 | "tags": [], 50 | "urlParameters": { 51 | "channel": "WEB_GB", 52 | "enabled": true, 53 | "locale": "en_GB", 54 | "attributes": { 55 | "Mug material": [ 56 | "Wood" 57 | ] 58 | } 59 | }, 60 | "resetUrlParameters": { 61 | "channel": "WEB_GB", 62 | "enabled": true, 63 | "locale": "en_GB" 64 | }, 65 | "name": "attributes", 66 | "items": [ 67 | { 68 | "tags": [], 69 | "urlParameters": [], 70 | "resetUrlParameters": { 71 | "channel": "WEB_GB", 72 | "enabled": true, 73 | "locale": "en_GB" 74 | }, 75 | "name": "Mug collection", 76 | "choices": { 77 | "HOLIDAY COLLECTION": { 78 | "active": false, 79 | "default": false, 80 | "urlParameters": { 81 | "channel": "WEB_GB", 82 | "enabled": true, 83 | "locale": "en_GB", 84 | "attributes": { 85 | "Mug material": [ 86 | "Wood" 87 | ], 88 | "Mug collection": [ 89 | "HOLIDAY COLLECTION" 90 | ] 91 | } 92 | }, 93 | "label": "HOLIDAY COLLECTION", 94 | "count": 1 95 | } 96 | } 97 | }, 98 | { 99 | "tags": [], 100 | "urlParameters": [], 101 | "resetUrlParameters": { 102 | "channel": "WEB_GB", 103 | "enabled": true, 104 | "locale": "en_GB" 105 | }, 106 | "name": "Mug material", 107 | "choices": { 108 | "Wood": { 109 | "active": true, 110 | "default": false, 111 | "urlParameters": { 112 | "channel": "WEB_GB", 113 | "enabled": true, 114 | "locale": "en_GB", 115 | "attributes": { 116 | "Mug material": [] 117 | } 118 | }, 119 | "label": "Wood", 120 | "count": 1 121 | } 122 | } 123 | }, 124 | { 125 | "tags": [], 126 | "urlParameters": [], 127 | "resetUrlParameters": { 128 | "channel": "WEB_GB", 129 | "enabled": true, 130 | "locale": "en_GB" 131 | }, 132 | "name": "Production year", 133 | "choices": { 134 | "2015": { 135 | "active": false, 136 | "default": false, 137 | "urlParameters": { 138 | "channel": "WEB_GB", 139 | "enabled": true, 140 | "locale": "en_GB", 141 | "attributes": { 142 | "Mug material": [ 143 | "Wood" 144 | ], 145 | "Production year": [ 146 | "2015" 147 | ] 148 | } 149 | }, 150 | "label": "2015", 151 | "count": 1 152 | } 153 | } 154 | } 155 | ] 156 | }, 157 | "attributesByCode": "@array@", 158 | "options": "@array@", 159 | "sort": "@array@" 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /tests/Responses/Expected/WEB_GB/de_DE/product_list_page_filtered_by_mug_material_wood_attribute.json: -------------------------------------------------------------------------------- 1 | { 2 | "page": 1, 3 | "limit": 9, 4 | "total": 1, 5 | "pages": 1, 6 | "items": [ 7 | { 8 | "id": 1, 9 | "code": "LOGAN_MUG_CODE", 10 | "name": "Logan Becher", 11 | "slug": "@string@", 12 | "taxons": "@array@", 13 | "variants": "@array@", 14 | "attributes": "@array@", 15 | "images": "@array@", 16 | "price": "@array@", 17 | "channelCode": "WEB_GB", 18 | "localeCode": "de_DE" 19 | } 20 | ], 21 | "filters": { 22 | "channel": "@array@", 23 | "enabled": "@array@", 24 | "taxonCode": "@array@", 25 | "taxonSlug": "@array@", 26 | "priceRange": "@array@", 27 | "locale": "@array@", 28 | "inStock": "@array@", 29 | "paginator": "@array@", 30 | "search": "@array@", 31 | "attributes": { 32 | "state": { 33 | "active": true, 34 | "value": { 35 | "Becher Material": [ 36 | "Holz" 37 | ] 38 | }, 39 | "urlParameters": { 40 | "attributes": { 41 | "Becher Material": [ 42 | "Holz" 43 | ] 44 | } 45 | }, 46 | "name": "attributes", 47 | "options": [] 48 | }, 49 | "tags": [], 50 | "urlParameters": { 51 | "channel": "WEB_GB", 52 | "enabled": true, 53 | "locale": "de_DE", 54 | "attributes": { 55 | "Becher Material": [ 56 | "Holz" 57 | ] 58 | } 59 | }, 60 | "resetUrlParameters": { 61 | "channel": "WEB_GB", 62 | "enabled": true, 63 | "locale": "de_DE" 64 | }, 65 | "name": "attributes", 66 | "items": [ 67 | { 68 | "tags": [], 69 | "urlParameters": [], 70 | "resetUrlParameters": { 71 | "channel": "WEB_GB", 72 | "enabled": true, 73 | "locale": "de_DE" 74 | }, 75 | "name": "Becher Material", 76 | "choices": { 77 | "Holz": { 78 | "active": true, 79 | "default": false, 80 | "urlParameters": { 81 | "channel": "WEB_GB", 82 | "enabled": true, 83 | "locale": "de_DE", 84 | "attributes": { 85 | "Becher Material": [] 86 | } 87 | }, 88 | "label": "Holz", 89 | "count": 1 90 | } 91 | } 92 | }, 93 | { 94 | "tags": [], 95 | "urlParameters": [], 96 | "resetUrlParameters": { 97 | "channel": "WEB_GB", 98 | "enabled": true, 99 | "locale": "de_DE" 100 | }, 101 | "name": "Becher Sammlung", 102 | "choices": { 103 | "FEIERTAGSKOLLEKTION": { 104 | "active": false, 105 | "default": false, 106 | "urlParameters": { 107 | "channel": "WEB_GB", 108 | "enabled": true, 109 | "locale": "de_DE", 110 | "attributes": { 111 | "Becher Material": [ 112 | "Holz" 113 | ], 114 | "Becher Sammlung": [ 115 | "FEIERTAGSKOLLEKTION" 116 | ] 117 | } 118 | }, 119 | "label": "FEIERTAGSKOLLEKTION", 120 | "count": 1 121 | } 122 | } 123 | }, 124 | { 125 | "tags": [], 126 | "urlParameters": [], 127 | "resetUrlParameters": { 128 | "channel": "WEB_GB", 129 | "enabled": true, 130 | "locale": "de_DE" 131 | }, 132 | "name": "Produktionsjahr", 133 | "choices": { 134 | "2015": { 135 | "active": false, 136 | "default": false, 137 | "urlParameters": { 138 | "channel": "WEB_GB", 139 | "enabled": true, 140 | "locale": "de_DE", 141 | "attributes": { 142 | "Becher Material": [ 143 | "Holz" 144 | ], 145 | "Produktionsjahr": [ 146 | "2015" 147 | ] 148 | } 149 | }, 150 | "label": "2015", 151 | "count": 1 152 | } 153 | } 154 | } 155 | ] 156 | }, 157 | "attributesByCode": "@array@", 158 | "options": "@array@", 159 | "sort": "@array@" 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /src/Factory/View/ProductListViewFactory.php: -------------------------------------------------------------------------------- 1 | productListViewClass = $productListViewClass; 56 | $this->productViewClass = $productViewClass; 57 | $this->productVariantViewClass = $productVariantViewClass; 58 | $this->attributeViewClass = $attributeViewClass; 59 | $this->imageViewClass = $imageViewClass; 60 | $this->priceViewClass = $priceViewClass; 61 | $this->taxonViewClass = $taxonViewClass; 62 | } 63 | 64 | /** 65 | * {@inheritdoc} 66 | */ 67 | public function createFromSearchResponse(SearchResponse $response): ProductListView 68 | { 69 | $result = $response->getResult(); 70 | $filters = $response->getFilters(); 71 | 72 | /** @var ProductListView $productListView */ 73 | $productListView = new $this->productListViewClass(); 74 | $productListView->filters = $filters; 75 | 76 | $pager = $filters['paginator']->getSerializableData()['pager']; 77 | $productListView->page = $pager['current_page']; 78 | $productListView->total = $pager['total_items']; 79 | $productListView->pages = $pager['num_pages']; 80 | $productListView->limit = $pager['limit']; 81 | 82 | /** @var ProductDocument $product */ 83 | foreach ($result as $product) { 84 | $productListView->items[] = $this->getProductView($product); 85 | } 86 | 87 | return $productListView; 88 | } 89 | 90 | /** 91 | * @param Collection|ImageDocument[] $images 92 | * 93 | * @return ImageView[] 94 | */ 95 | private function getImageViews(Collection $images): array 96 | { 97 | $imageViews = []; 98 | foreach ($images as $image) { 99 | /** @var ImageView $imageView */ 100 | $imageView = new $this->imageViewClass(); 101 | $imageView->code = $image->getCode(); 102 | $imageView->path = $image->getPath(); 103 | 104 | $imageViews[] = $imageView; 105 | } 106 | 107 | return $imageViews; 108 | } 109 | 110 | /** 111 | * @param Collection|TaxonDocument[] $taxons 112 | * @param TaxonDocument|null $mainTaxonDocument 113 | * 114 | * @return TaxonView 115 | */ 116 | private function getTaxonView(Collection $taxons, ?TaxonDocument $mainTaxonDocument): TaxonView 117 | { 118 | /** @var TaxonView $taxonView */ 119 | $taxonView = new $this->taxonViewClass(); 120 | 121 | $taxonView->main = null === $mainTaxonDocument ? null : $mainTaxonDocument->getCode(); 122 | foreach ($taxons as $taxon) { 123 | $taxonView->others[] = $taxon->getCode(); 124 | } 125 | 126 | return $taxonView; 127 | } 128 | 129 | /** 130 | * @param Collection|AttributeDocument[] $attributes 131 | * 132 | * @return AttributeView[] 133 | */ 134 | private function getAttributeViews(Collection $attributes): array 135 | { 136 | $attributeValueViews = []; 137 | foreach ($attributes as $attribute) { 138 | /** @var AttributeView $attributeView */ 139 | $attributeView = new $this->attributeViewClass(); 140 | $attributeView->code = $attribute->getCode(); 141 | $attributeView->value = $attribute->getValue(); 142 | $attributeView->name = $attribute->getName(); 143 | 144 | $attributeValueViews[$attribute->getCode()] = $attributeView; 145 | } 146 | 147 | return $attributeValueViews; 148 | } 149 | 150 | /** 151 | * @param PriceDocument $price 152 | * 153 | * @return PriceView 154 | */ 155 | private function getPriceView(PriceDocument $price): PriceView 156 | { 157 | /** @var PriceView $priceView */ 158 | $priceView = new $this->priceViewClass(); 159 | $priceView->current = $price->getAmount(); 160 | $priceView->currency = $price->getCurrency(); 161 | $priceView->original = $price->getOriginalAmount(); 162 | 163 | return $priceView; 164 | } 165 | 166 | /** 167 | * @param VariantDocument[]|Collection $variants 168 | * 169 | * @return array 170 | */ 171 | private function getVariantViews(Collection $variants): array 172 | { 173 | $variantViews = []; 174 | foreach ($variants as $variant) { 175 | /** @var VariantView $variantView */ 176 | $variantView = new $this->productVariantViewClass(); 177 | $variantView->id = $variant->getId(); 178 | $variantView->price = $this->getPriceView($variant->getPrice()); 179 | $variantView->code = $variant->getCode(); 180 | $variantView->name = $variant->getName(); 181 | $variantView->stock = $variant->getStock(); 182 | $variantView->isTracked = $variant->getIsTracked(); 183 | 184 | if ($variant->getImages()->count() > 0) { 185 | $variantView->images = $this->getImageViews($variant->getImages()); 186 | } 187 | $variantViews[] = $variantView; 188 | } 189 | 190 | return $variantViews; 191 | } 192 | 193 | /** 194 | * @param ProductDocument $product 195 | * 196 | * @return ProductView 197 | */ 198 | private function getProductView(ProductDocument $product): ProductView 199 | { 200 | /** @var ProductView $productView */ 201 | $productView = new $this->productViewClass(); 202 | $productView->id = $product->getId(); 203 | $productView->slug = $product->getSlug(); 204 | $productView->name = $product->getName(); 205 | $productView->code = $product->getCode(); 206 | $productView->rating = $product->getAverageReviewRating(); 207 | $productView->localeCode = $product->getLocaleCode(); 208 | $productView->channelCode = $product->getChannelCode(); 209 | if ($product->getImages()->count() > 0) { 210 | $productView->images = $this->getImageViews($product->getImages()); 211 | } 212 | $productView->taxons = $this->getTaxonView($product->getTaxons(), $product->getMainTaxon()); 213 | $productView->attributes = $this->getAttributeViews($product->getAttributes()); 214 | $productView->variants = $this->getVariantViews($product->getVariants()); 215 | $productView->price = $this->getPriceView($product->getPrice()); 216 | 217 | return $productView; 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /src/Filter/Widget/OptionMultiDynamicAggregate.php: -------------------------------------------------------------------------------- 1 | getAggregation(sprintf('%s-filter', $filterName)); 38 | 39 | foreach ($values as $name => $value) { 40 | $data[$name] = $aggregation->find(sprintf('%s.%s.name', $name, $filterName)); 41 | } 42 | 43 | $data['all-selected'] = $aggregation->find(sprintf('all-selected.%s.%s.name', $filterName, $filterName)); 44 | 45 | return $data; 46 | } 47 | 48 | /** 49 | * {@inheritdoc} 50 | */ 51 | public function modifySearch(Search $search, FilterState $state = null, SearchRequest $request = null) 52 | { 53 | if ($state && $state->isActive()) { 54 | $search->addPostFilter($this->getFilterQuery($state->getValue())); 55 | } 56 | } 57 | 58 | /** 59 | * Forms $unsortedChoices array with all possible choices. 60 | * 0 is assigned to the document count of the choices. 61 | * 62 | * @param DocumentIterator $result 63 | * @param ViewData $data 64 | * 65 | * @return array 66 | */ 67 | protected function formInitialUnsortedChoices($result, $data) 68 | { 69 | $unsortedChoices = []; 70 | $urlParameters = array_merge( 71 | $data->getResetUrlParameters(), 72 | $data->getState()->getUrlParameters() 73 | ); 74 | 75 | foreach ($result->getAggregation($data->getName())->getAggregation($data->getName())->getAggregation('name') as $nameBucket) { 76 | $groupName = $nameBucket['key']; 77 | 78 | foreach ($nameBucket->getAggregation('value') as $bucket) { 79 | $bucketArray = ['key' => $bucket['key'], 'doc_count' => 0]; 80 | $choice = $this->createChoice($data, $bucket['key'], '', $bucketArray, $urlParameters); 81 | $unsortedChoices[$groupName][$bucket['key']] = $choice; 82 | } 83 | } 84 | 85 | return $unsortedChoices; 86 | } 87 | 88 | /** 89 | * {@inheritdoc} 90 | */ 91 | public function getViewData(DocumentIterator $result, ViewData $data) 92 | { 93 | $unsortedChoices = []; 94 | $activeNames = $data->getState()->isActive() ? array_keys($data->getState()->getValue()) : []; 95 | $filterAggregations = $this->fetchAggregation($result, $data->getName(), $data->getState()->getValue()); 96 | 97 | if ($this->getShowZeroChoices()) { 98 | $unsortedChoices = $this->formInitialUnsortedChoices($result, $data); 99 | } 100 | 101 | /** @var AggregationValue $bucket */ 102 | foreach ($filterAggregations as $activeName => $filterAggregation) { 103 | foreach ($filterAggregation as $nameAggregation) { 104 | $name = $nameAggregation['key']; 105 | 106 | if (($name != $activeName && $activeName != 'all-selected') || 107 | ($activeName == 'all-selected' && in_array($name, $activeNames))) { 108 | continue; 109 | } 110 | 111 | foreach ($nameAggregation['value']['buckets'] as $bucket) { 112 | $choice = $this->createChoice($data, $name, $activeName, $bucket); 113 | $unsortedChoices[$name][$bucket['key']] = $choice; 114 | } 115 | 116 | $this->addViewDataItem($data, $name, $unsortedChoices[$name]); 117 | unset($unsortedChoices[$name]); 118 | } 119 | } 120 | 121 | /** @var ViewData\AggregateViewData $data */ 122 | $data->sortItems(); 123 | 124 | return $data; 125 | } 126 | 127 | /** 128 | * {@inheritdoc} 129 | */ 130 | public function preProcessSearch(Search $search, Search $relatedSearch, FilterState $state = null) 131 | { 132 | [$parent, $child, $field] = explode('>', $this->getDocumentField()); 133 | $filter = !empty($filter = $relatedSearch->getPostFilters()) ? $filter : new MatchAllQuery(); 134 | $parentAggregation = new NestedAggregation($state->getName(), $parent); 135 | $childAggregation = new NestedAggregation($state->getName(), $child); 136 | $nameAggregation = new TermsAggregation('name', $this->getNameField()); 137 | $valueAggregation = new TermsAggregation('value', $field); 138 | $filterAggregation = new FilterAggregation($state->getName() . '-filter'); 139 | $nameAggregation->addAggregation($valueAggregation); 140 | $childAggregation->addAggregation($nameAggregation); 141 | $parentAggregation->addAggregation($childAggregation); 142 | $filterAggregation->setFilter($filter); 143 | 144 | if ($this->getSortType()) { 145 | $valueAggregation->addParameter('order', [$this->getSortType() => $this->getSortOrder()]); 146 | } 147 | 148 | if ($this->getOption('size')) { 149 | $valueAggregation->addParameter('size', $this->getOption('size')); 150 | } 151 | 152 | if ($state->isActive()) { 153 | foreach ($state->getValue() as $key => $term) { 154 | $terms = $state->getValue(); 155 | unset($terms[$key]); 156 | 157 | $this->addSubFilterAggregation( 158 | $filterAggregation, 159 | $childAggregation, 160 | $terms, 161 | $key 162 | ); 163 | } 164 | } 165 | 166 | $this->addSubFilterAggregation( 167 | $filterAggregation, 168 | $parentAggregation, 169 | $state->getValue() ? $state->getValue() : [], 170 | 'all-selected' 171 | ); 172 | 173 | $search->addAggregation($filterAggregation); 174 | 175 | if ($this->getShowZeroChoices()) { 176 | $search->addAggregation($parentAggregation); 177 | } 178 | } 179 | 180 | /** 181 | * @param array $terms 182 | * 183 | * @return BoolQuery 184 | */ 185 | private function getFilterQuery($terms) 186 | { 187 | [$parent, $child, $field] = explode('>', $this->getDocumentField()); 188 | $boolQuery = new BoolQuery(); 189 | foreach ($terms as $groupName => $values) { 190 | $innerBoolQuery = new BoolQuery(); 191 | 192 | foreach ($values as $value) { 193 | $nestedBoolQuery = new BoolQuery(); 194 | $nestedBoolQuery->add(new TermQuery($field, $value)); 195 | $nestedBoolQuery->add(new TermQuery($this->getNameField(), $groupName)); 196 | 197 | $inStockBoolQuery = new BoolQuery(); 198 | 199 | $inStockBoolQuery->add(new RangeQuery('variants.stock', ['gte' => 1]), BoolQuery::SHOULD); 200 | $inStockBoolQuery->add(new TermQuery('variants.is_tracked', false), BoolQuery::SHOULD); 201 | 202 | $childBoolQuery = new BoolQuery(); 203 | $childBoolQuery->add($inStockBoolQuery, BoolQuery::MUST); 204 | $childBoolQuery->add(new NestedQuery($child, $nestedBoolQuery), BoolQuery::MUST); 205 | 206 | $nestedQuery = new NestedQuery($parent, $childBoolQuery); 207 | 208 | $innerBoolQuery->add( 209 | $nestedQuery, 210 | 211 | BoolQuery::SHOULD 212 | ); 213 | } 214 | 215 | $boolQuery->add($innerBoolQuery); 216 | } 217 | 218 | return $boolQuery; 219 | } 220 | } 221 | -------------------------------------------------------------------------------- /src/Factory/Document/ProductDocumentFactory.php: -------------------------------------------------------------------------------- 1 | assertClassExtends($productDocumentClass, ProductDocument::class); 64 | $this->productDocumentClass = $productDocumentClass; 65 | 66 | $this->attributeDocumentFactory = $attributeDocumentFactory; 67 | $this->imageDocumentFactory = $imageDocumentFactory; 68 | $this->priceDocumentFactory = $priceDocumentFactory; 69 | $this->taxonDocumentFactory = $taxonDocumentFactory; 70 | $this->variantDocumentFactory = $variantDocumentFactory; 71 | $this->attributeWhitelist = $attributeWhitelist; 72 | } 73 | 74 | /** 75 | * Create a product document from the product object with all it's related documents 76 | * 77 | * @param ProductInterface $product 78 | * @param LocaleInterface $locale 79 | * @param ChannelInterface $channel 80 | * 81 | * @return ProductDocument 82 | */ 83 | public function create( 84 | ProductInterface $product, 85 | LocaleInterface $locale, 86 | ChannelInterface $channel 87 | ): ProductDocument { 88 | /** @var ProductVariantInterface[] $productVariants */ 89 | $productVariants = $product->getVariants()->filter(function (ProductVariantInterface $productVariant) use ($channel): bool { 90 | return $productVariant->hasChannelPricingForChannel($channel); 91 | }); 92 | 93 | /** 94 | * @var ArrayObject 95 | */ 96 | $iterator = $productVariants->getIterator(); 97 | $iterator->uasort( 98 | function (ProductVariantInterface $a, ProductVariantInterface $b) { 99 | return $a->getName() <=> $b->getName(); 100 | } 101 | ); 102 | $variantDocuments = []; 103 | foreach ($iterator as $variant) { 104 | $variantDocuments[] = $this->variantDocumentFactory->create($variant, $channel, $locale); 105 | } 106 | 107 | /** @var ImageDocument[] $imageDocuments */ 108 | $imageDocuments = []; 109 | foreach ($product->getImages() as $productImage) { 110 | foreach ($productVariants as $variant) { 111 | if ($variant->hasImage($productImage)) { 112 | continue 2; 113 | } 114 | } 115 | 116 | $imageDocuments[] = $this->imageDocumentFactory->create($productImage); 117 | } 118 | 119 | /** @var TaxonDocument[] $taxonDocuments */ 120 | $taxonDocuments = []; 121 | foreach ($product->getProductTaxons() as $syliusProductTaxon) { 122 | $taxonDocuments[] = $this->taxonDocumentFactory->create( 123 | $syliusProductTaxon->getTaxon(), 124 | $locale, 125 | $syliusProductTaxon->getPosition() 126 | ); 127 | } 128 | 129 | /** @var ProductTranslationInterface|TranslationInterface $productTranslation */ 130 | $productTranslation = $product->getTranslation($locale->getCode()); 131 | 132 | $attributeDocuments = $this->getAttributeDocuments($product, $locale, $channel); 133 | 134 | /** @var ProductDocument $productDocument */ 135 | $productDocument = new $this->productDocumentClass(); 136 | $productDocument->setUuid(Uuid::uuid4()->toString()); 137 | $productDocument->setId($product->getId()); 138 | $productDocument->setEnabled($product->isEnabled()); 139 | $productDocument->setLocaleCode($locale->getCode()); 140 | $productDocument->setSlug($productTranslation->getSlug()); 141 | $productDocument->setName($productTranslation->getName()); 142 | $productDocument->setDescription($productTranslation->getDescription()); 143 | $productDocument->setChannelCode($channel->getCode()); 144 | $productDocument->setCode($product->getCode()); 145 | $productDocument->setCreatedAt($product->getCreatedAt()); 146 | $productDocument->setSynchronisedAt(new \DateTime('now')); 147 | $productDocument->setAverageReviewRating($product->getAverageRating()); 148 | $productDocument->setVariants(new Collection($variantDocuments)); 149 | $productDocument->setImages(new Collection($imageDocuments)); 150 | $productDocument->setTaxons(new Collection($taxonDocuments)); 151 | $productDocument->setAttributes(new Collection($attributeDocuments)); 152 | 153 | /** 154 | * Set smallest product variant price, used for search by price 155 | */ 156 | $productDocument->setPrice( 157 | $this->priceDocumentFactory->create( 158 | $this->getMinimalPriceFromVariants($productVariants, $channel), 159 | $channel->getBaseCurrency() 160 | ) 161 | ); 162 | 163 | if (null !== $product->getMainTaxon()) { 164 | $productDocument->setMainTaxon( 165 | $this->taxonDocumentFactory->create($product->getMainTaxon(), $locale) 166 | ); 167 | } 168 | 169 | return $productDocument; 170 | } 171 | 172 | /** 173 | * @param ProductVariantInterface[]|DoctrineCollection $variants 174 | * @param ChannelInterface $channel 175 | * 176 | * @return ChannelPricingInterface 177 | */ 178 | private function getMinimalPriceFromVariants($variants, ChannelInterface $channel): ChannelPricingInterface 179 | { 180 | /** @var ChannelPricingInterface $minProductChannelPrice */ 181 | $minProductChannelPrice = $variants->first()->getChannelPricingForChannel($channel); 182 | 183 | foreach ($variants as $variant) { 184 | $channelPrice = $variant->getChannelPricingForChannel($channel); 185 | if ( 186 | ($variant->isTracked() && $variant->getOnHold() - $variant->getOnHold( 187 | ) > 0 && $minProductChannelPrice->getPrice() < $channelPrice->getPrice()) 188 | || (!$variant->isTracked() && $minProductChannelPrice->getPrice() < $channelPrice->getPrice()) 189 | ) { 190 | $minProductChannelPrice = $channelPrice; 191 | } 192 | } 193 | 194 | return $minProductChannelPrice; 195 | } 196 | 197 | /** 198 | * @param string $class 199 | * @param string $parentClass 200 | * 201 | * @throws \InvalidArgumentException 202 | */ 203 | private function assertClassExtends(string $class, string $parentClass) 204 | { 205 | if ($class !== $parentClass && !in_array($parentClass, class_parents($class), true)) { 206 | throw new \InvalidArgumentException(sprintf('Class %s MUST extend class %s!', $class, $parentClass)); 207 | } 208 | } 209 | 210 | /** 211 | * @param ProductInterface $product 212 | * @param LocaleInterface $locale 213 | * @param ChannelInterface $channel 214 | * 215 | * @return array 216 | */ 217 | private function getAttributeDocuments( 218 | ProductInterface $product, 219 | LocaleInterface $locale, 220 | ChannelInterface $channel 221 | ): array { 222 | $productAttributes = $product->getAttributesByLocale( 223 | $locale->getCode(), 224 | $channel->getDefaultLocale()->getCode() 225 | ); 226 | 227 | $attributeDocuments = []; 228 | foreach ($productAttributes as $syliusProductAttributeValue) { 229 | if (in_array($syliusProductAttributeValue->getCode(), $this->attributeWhitelist, true) || empty($this->attributeWhitelist)) { 230 | $attributeDocuments = array_merge( 231 | $attributeDocuments, 232 | $this->attributeDocumentFactory->create( 233 | $syliusProductAttributeValue->getValue(), 234 | $locale, 235 | $syliusProductAttributeValue 236 | ) 237 | ); 238 | } 239 | } 240 | 241 | return $attributeDocuments; 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /src/Document/ProductDocument.php: -------------------------------------------------------------------------------- 1 | attributes = new Collection(); 153 | $this->taxons = new Collection(); 154 | $this->images = new Collection(); 155 | $this->variants = new Collection(); 156 | } 157 | 158 | /** 159 | * @return string 160 | */ 161 | public function getUuid(): string 162 | { 163 | return $this->uuid; 164 | } 165 | 166 | /** 167 | * @param string $uuid 168 | */ 169 | public function setUuid(string $uuid): void 170 | { 171 | $this->uuid = $uuid; 172 | } 173 | 174 | /** 175 | * @return mixed 176 | */ 177 | public function getId() 178 | { 179 | return $this->id; 180 | } 181 | 182 | /** 183 | * @param mixed $id 184 | */ 185 | public function setId($id): void 186 | { 187 | $this->id = $id; 188 | } 189 | 190 | /** 191 | * @return string 192 | */ 193 | public function getCode(): string 194 | { 195 | return $this->code; 196 | } 197 | 198 | /** 199 | * @param string $code 200 | */ 201 | public function setCode(string $code): void 202 | { 203 | $this->code = $code; 204 | } 205 | 206 | /** 207 | * @return string 208 | */ 209 | public function getName(): string 210 | { 211 | return $this->name; 212 | } 213 | 214 | /** 215 | * @param string $name 216 | */ 217 | public function setName(string $name): void 218 | { 219 | $this->name = $name; 220 | } 221 | 222 | /** 223 | * @return bool 224 | */ 225 | public function isEnabled(): bool 226 | { 227 | return $this->enabled; 228 | } 229 | 230 | /** 231 | * @param bool $enabled 232 | */ 233 | public function setEnabled(bool $enabled): void 234 | { 235 | $this->enabled = $enabled; 236 | } 237 | 238 | /** 239 | * @return string 240 | */ 241 | public function getSlug(): string 242 | { 243 | return $this->slug; 244 | } 245 | 246 | /** 247 | * @param string $slug 248 | */ 249 | public function setSlug(string $slug): void 250 | { 251 | $this->slug = $slug; 252 | } 253 | 254 | /** 255 | * @return string 256 | */ 257 | public function getChannelCode(): string 258 | { 259 | return $this->channelCode; 260 | } 261 | 262 | /** 263 | * @param string $channelCode 264 | */ 265 | public function setChannelCode(string $channelCode): void 266 | { 267 | $this->channelCode = $channelCode; 268 | } 269 | 270 | /** 271 | * @return string 272 | */ 273 | public function getLocaleCode(): string 274 | { 275 | return $this->localeCode; 276 | } 277 | 278 | /** 279 | * @param string $localeCode 280 | */ 281 | public function setLocaleCode(string $localeCode): void 282 | { 283 | $this->localeCode = $localeCode; 284 | } 285 | 286 | /** 287 | * @return string 288 | */ 289 | public function getDescription(): ?string 290 | { 291 | return $this->description; 292 | } 293 | 294 | /** 295 | * @param string $description 296 | */ 297 | public function setDescription(?string $description): void 298 | { 299 | $this->description = $description; 300 | } 301 | 302 | /** 303 | * @return PriceDocument 304 | */ 305 | public function getPrice(): PriceDocument 306 | { 307 | return $this->price; 308 | } 309 | 310 | /** 311 | * @param PriceDocument $price 312 | */ 313 | public function setPrice(PriceDocument $price): void 314 | { 315 | $this->price = $price; 316 | } 317 | 318 | /** 319 | * @return TaxonDocument 320 | */ 321 | public function getMainTaxon(): ?TaxonDocument 322 | { 323 | return $this->mainTaxon; 324 | } 325 | 326 | /** 327 | * @param TaxonDocument $mainTaxon 328 | */ 329 | public function setMainTaxon(TaxonDocument $mainTaxon): void 330 | { 331 | $this->mainTaxon = $mainTaxon; 332 | } 333 | 334 | /** 335 | * @return Collection|TaxonDocument[] 336 | */ 337 | public function getTaxons(): Collection 338 | { 339 | return $this->taxons; 340 | } 341 | 342 | /** 343 | * @param Collection|TaxonDocument[] $taxons 344 | */ 345 | public function setTaxons($taxons): void 346 | { 347 | $this->taxons = $taxons; 348 | } 349 | 350 | /** 351 | * @return Collection 352 | */ 353 | public function getAttributes(): Collection 354 | { 355 | return $this->attributes; 356 | } 357 | 358 | /** 359 | * @param Collection $attributes 360 | */ 361 | public function setAttributes(Collection $attributes): void 362 | { 363 | $this->attributes = $attributes; 364 | } 365 | 366 | /** 367 | * @return Collection 368 | */ 369 | public function getImages(): Collection 370 | { 371 | return $this->images; 372 | } 373 | 374 | /** 375 | * @param Collection $images 376 | */ 377 | public function setImages(Collection $images): void 378 | { 379 | $this->images = $images; 380 | } 381 | 382 | /** 383 | * @return float 384 | */ 385 | public function getAverageReviewRating(): ?float 386 | { 387 | return $this->averageReviewRating; 388 | } 389 | 390 | /** 391 | * @param float $averageReviewRating 392 | */ 393 | public function setAverageReviewRating(float $averageReviewRating): void 394 | { 395 | $this->averageReviewRating = $averageReviewRating; 396 | } 397 | 398 | /** 399 | * @return \DateTimeInterface 400 | */ 401 | public function getCreatedAt(): \DateTimeInterface 402 | { 403 | return $this->createdAt; 404 | } 405 | 406 | /** 407 | * @param \DateTimeInterface $createdAt 408 | */ 409 | public function setCreatedAt(\DateTimeInterface $createdAt): void 410 | { 411 | $this->createdAt = $createdAt; 412 | } 413 | 414 | /** 415 | * @return \DateTimeInterface 416 | */ 417 | public function getSynchronisedAt(): \DateTimeInterface 418 | { 419 | return $this->synchronisedAt; 420 | } 421 | 422 | /** 423 | * @param \DateTimeInterface $synchronisedAt 424 | */ 425 | public function setSynchronisedAt(\DateTimeInterface $synchronisedAt): void 426 | { 427 | $this->synchronisedAt = $synchronisedAt; 428 | } 429 | 430 | /** 431 | * @return Collection 432 | */ 433 | public function getVariants(): Collection 434 | { 435 | return $this->variants; 436 | } 437 | 438 | /** 439 | * @param Collection $variants 440 | */ 441 | public function setVariants(Collection $variants): void 442 | { 443 | $this->variants = $variants; 444 | } 445 | } 446 | -------------------------------------------------------------------------------- /tests/Factory/ProductDocumentFactoryTest.php: -------------------------------------------------------------------------------- 1 | productRepository = static::$kernel->getContainer()->get('doctrine.orm.entity_manager')->getRepository( 47 | Product::class 48 | ) 49 | ; 50 | $this->localeRepository = static::$kernel->getContainer()->get('doctrine.orm.entity_manager')->getRepository( 51 | Locale::class 52 | ) 53 | ; 54 | $this->channelRepository = static::$kernel->getContainer()->get('doctrine.orm.entity_manager')->getRepository( 55 | Channel::class 56 | ) 57 | ; 58 | } 59 | 60 | /** 61 | * @test 62 | */ 63 | public function it_creates_product_document_from_sylius_product_model() 64 | { 65 | /** @var ChannelInterface $syliusChannel */ 66 | $syliusChannel = $this->channelRepository->findOneByCode('WEB_GB'); 67 | /** @var Locale $syliusLocale */ 68 | $syliusLocale = $this->localeRepository->findOneBy(['code' => 'en_GB']); 69 | $createdAt = \DateTime::createFromFormat(\DateTime::W3C, '2017-04-18T16:12:55+02:00'); 70 | 71 | /** @var Product $syliusProduct */ 72 | $syliusProduct = $this->productRepository->findOneByChannelAndSlug( 73 | $syliusChannel, 74 | $syliusLocale->getCode(), 75 | 'logan-mug' 76 | ); 77 | $syliusProduct->setCreatedAt($createdAt); 78 | 79 | $factory = new ProductDocumentFactory( 80 | ProductDocument::class, 81 | new AttributeDocumentFactory(AttributeDocument::class), 82 | new ImageDocumentFactory(ImageDocument::class), 83 | new PriceDocumentFactory(PriceDocument::class), 84 | new TaxonDocumentFactory(TaxonDocument::class, new ImageDocumentFactory(ImageDocument::class)), 85 | new VariantDocumentFactory( 86 | VariantDocument::class, 87 | new PriceDocumentFactory(PriceDocument::class), 88 | new ImageDocumentFactory(ImageDocument::class), 89 | new OptionDocumentFactory(OptionDocument::class) 90 | ), 91 | ['MUG_COLLECTION_CODE', 'MUG_MATERIAL_CODE', 'PRODUCTION_YEAR'] 92 | ); 93 | /** @var ProductDocument $product */ 94 | $product = $factory->create( 95 | $syliusProduct, 96 | $syliusLocale, 97 | $syliusChannel 98 | ); 99 | 100 | $taxon = $this->makeMainTaxon(); 101 | 102 | $productTaxons = $this->makeProductTaxons(); 103 | 104 | $productAttributes = $this->makeProductAttributes(); 105 | 106 | $this->assertEquals($product->getCode(), $product->getCode()); 107 | $this->assertEquals($product->getName(), $product->getName()); 108 | $this->assertEquals('en_GB', $product->getLocaleCode()); 109 | $this->assertEquals(new Collection($productAttributes), $product->getAttributes()); 110 | $this->assertEquals(1000, $product->getPrice()->getAmount()); 111 | $this->assertEquals('GBP', $product->getPrice()->getCurrency()); 112 | $this->assertEquals('en_GB', $product->getLocaleCode()); 113 | $this->assertEquals('WEB_GB', $product->getChannelCode()); 114 | $this->assertEquals('logan-mug', $product->getSlug()); 115 | $this->assertEquals('Logan Mug', $product->getName()); 116 | $this->assertEquals($createdAt, $product->getCreatedAt()); 117 | $this->assertEquals('Logan Mug', $product->getDescription()); 118 | $this->assertEquals($taxon, $product->getMainTaxon()); 119 | $this->assertEquals(new Collection($productTaxons), $product->getTaxons()); 120 | $this->assertEquals(0.0, $product->getAverageReviewRating()); 121 | } 122 | 123 | /** 124 | * @test 125 | */ 126 | public function it_creates_product_document_only_with_whitelisted_attributes() 127 | { 128 | /** @var ChannelInterface $syliusChannel */ 129 | $syliusChannel = $this->channelRepository->findOneByCode('WEB_GB'); 130 | /** @var Locale $syliusLocale */ 131 | $syliusLocale = $this->localeRepository->findOneBy(['code' => 'en_GB']); 132 | $createdAt = \DateTime::createFromFormat(\DateTime::W3C, '2017-04-18T16:12:55+02:00'); 133 | 134 | /** @var Product $syliusProduct */ 135 | $syliusProduct = $this->productRepository->findOneByChannelAndSlug( 136 | $syliusChannel, 137 | $syliusLocale->getCode(), 138 | 'logan-mug' 139 | ); 140 | $syliusProduct->setCreatedAt($createdAt); 141 | 142 | $factory = new ProductDocumentFactory( 143 | ProductDocument::class, 144 | new AttributeDocumentFactory(AttributeDocument::class), 145 | new ImageDocumentFactory(ImageDocument::class), 146 | new PriceDocumentFactory(PriceDocument::class), 147 | new TaxonDocumentFactory(TaxonDocument::class, new ImageDocumentFactory(ImageDocument::class)), 148 | new VariantDocumentFactory( 149 | VariantDocument::class, 150 | new PriceDocumentFactory(PriceDocument::class), 151 | new ImageDocumentFactory(ImageDocument::class), 152 | new OptionDocumentFactory(OptionDocument::class) 153 | ), 154 | ['PRODUCTION_YEAR'] 155 | ); 156 | /** @var ProductDocument $product */ 157 | $product = $factory->create( 158 | $syliusProduct, 159 | $syliusLocale, 160 | $syliusChannel 161 | ); 162 | 163 | $taxon = $this->makeMainTaxon(); 164 | 165 | $productTaxons = $this->makeProductTaxons(); 166 | 167 | $productAttribute = $this->makeProductionYearAttribute(); 168 | 169 | $this->assertEquals($product->getCode(), $product->getCode()); 170 | $this->assertEquals($product->getName(), $product->getName()); 171 | $this->assertEquals('en_GB', $product->getLocaleCode()); 172 | $this->assertEquals( 173 | new Collection([$productAttribute]), 174 | $product->getAttributes() 175 | ); 176 | $this->assertEquals(1000, $product->getPrice()->getAmount()); 177 | $this->assertEquals('GBP', $product->getPrice()->getCurrency()); 178 | $this->assertEquals('en_GB', $product->getLocaleCode()); 179 | $this->assertEquals('WEB_GB', $product->getChannelCode()); 180 | $this->assertEquals('logan-mug', $product->getSlug()); 181 | $this->assertEquals('Logan Mug', $product->getName()); 182 | $this->assertEquals($createdAt, $product->getCreatedAt()); 183 | $this->assertEquals('Logan Mug', $product->getDescription()); 184 | $this->assertEquals($taxon, $product->getMainTaxon()); 185 | $this->assertEquals(new Collection($productTaxons), $product->getTaxons()); 186 | $this->assertEquals(0.0, $product->getAverageReviewRating()); 187 | } 188 | 189 | /** 190 | * @return TaxonDocument 191 | */ 192 | private function makeMainTaxon(): TaxonDocument 193 | { 194 | $taxon = new TaxonDocument(); 195 | $taxon->setCode('MUG'); 196 | $taxon->setPosition(0); 197 | $taxon->setSlug('categories/mugs'); 198 | $taxon->setDescription('Lorem ipsum'); 199 | 200 | return $taxon; 201 | } 202 | 203 | /** 204 | * @return array 205 | */ 206 | private function makeProductTaxons(): array 207 | { 208 | $productTaxons = []; 209 | $productTaxon = new TaxonDocument(); 210 | $productTaxon->setCode('MUG'); 211 | $productTaxon->setSlug('categories/mugs'); 212 | $productTaxon->setPosition(0); 213 | $productTaxon->setDescription('Lorem ipsum'); 214 | $productTaxons[] = $productTaxon; 215 | $productTaxon = new TaxonDocument(); 216 | $productTaxon->setCode('BRAND'); 217 | $productTaxon->setSlug('brands'); 218 | $productTaxon->setPosition(3); 219 | $productTaxon->setDescription('Lorem ipsum'); 220 | $productTaxons[] = $productTaxon; 221 | 222 | return $productTaxons; 223 | } 224 | 225 | /** 226 | * @return AttributeDocument 227 | */ 228 | private function makeProductionYearAttribute(): AttributeDocument 229 | { 230 | $productAttribute = new AttributeDocument(); 231 | $productAttribute->setCode('PRODUCTION_YEAR'); 232 | $productAttribute->setName('Production year'); 233 | $productAttribute->setValue('2015'); 234 | 235 | return $productAttribute; 236 | } 237 | 238 | private function makeProductAttributes(): array 239 | { 240 | $productAttributes = []; 241 | 242 | $productAttribute = new AttributeDocument(); 243 | $productAttribute->setCode('MUG_COLLECTION_CODE'); 244 | $productAttribute->setName('Mug collection'); 245 | $productAttribute->setValue('HOLIDAY COLLECTION'); 246 | $productAttributes[] = $productAttribute; 247 | 248 | $productAttribute = new AttributeDocument(); 249 | $productAttribute->setCode('MUG_MATERIAL_CODE'); 250 | $productAttribute->setName('Mug material'); 251 | $productAttribute->setValue('Wood'); 252 | $productAttributes[] = $productAttribute; 253 | 254 | $productAttributes[] = $this->makeProductionYearAttribute(); 255 | 256 | return $productAttributes; 257 | } 258 | } 259 | --------------------------------------------------------------------------------