In this guide we introduce the Elasticsearch (ES) integration for Shopware.
Shopware uses three bundles for the ES implementation:
Shopware uses the official PHP low-level client for Elasticsearch elasticsearch-php as well as the ONGR Elasticsearch DSL library ElasticsearchDSL that provides an objective query builder.
The following list contains all relevant events, interfaces and public API calls for Elasticsearch:
| Console Command | Description |
|---|---|
| sw:es:analyze | Helper tool to test the integrated analyzers. |
| sw:es:backlog:clear | Remove backlog entries that are already synchronized. |
| sw:es:backlog:sync | Synchronize events from the backlog to the live index. |
| sw:es:index:cleanup | Remove unused Elasticsearch indices. |
| sw:es:index:populate | Reindex all shops into new indexes and switch the live system alias after the index process. |
| sw:es:backend:index:populate | Reindex all documents for the backend. |
| sw:es:backend:sync | Synchronize events from the backend backlog to the live index. |
| sw:es:switch:alias | Switch live system aliases. |
| Interface | Description |
|---|---|
| DataIndexerInterface | Required to add new data indexer |
| MappingInterface | Required to add new data mappings |
| SettingsInterface | Required to add new ES settings |
| SynchronizerInterface | Required to add new data synchronizer |
| ShopAnalyzerInterface | Defines which ES analyzer(s) is used in which shop |
| SearchTermQueryBuilderInterface | Builds the search query for product search |
| HandlerInterface | Allows handling criteria parts in ES number searches |
| ResultHydratorInterface | Allows hydrating ES number search results |
| DI container service | Description |
|---|---|
| shopware_elastic_search.client | ES client for communication |
| shopware_elastic_search.client.logger | Logs specific ES requests and their responses |
| shopware_elastic_search.shop_indexer | Starts indexing process for all shops |
| shopware_elastic_search.shop_analyzer | Defines which ES analyzer(s) is used in which shop |
| shopware_elastic_search.backlog_processor | Process the backlog queue |
| shopware_search_es.product_number_search | ProductNumberSearch using ES |
| shopware_search_es.search_term_query_builder | Builds the search query for product searches |
| shopware_es_backend.indexer | Starts backend indexing process for all shops |
| DI container tag | Description |
|---|---|
| shopware_elastic_search.data_indexer | Registers a new data indexer |
| shopware_elastic_search.mapping | Registers a new data mapping |
| shopware_elastic_search.synchronizer | Registers a new data synchronizer |
| shopware_elastic_search.settings | Registers a new ES settings class |
| shopware_search_es.search_handler | Registers a new search handler |
| Event | Description |
|---|---|
| Shopware_ESIndexingBundle_Collect_Indexer | Registers a new data indexer |
| Shopware_ESIndexingBundle_Collect_Mapping | Registers a new data mapping |
| Shopware_ESIndexingBundle_Collect_Synchronizer | Registers a new data synchronizer |
| Shopware_ESIndexingBundle_Collect_Settings | Registers a new ES settings class |
| Shopware_SearchBundleES_Collect_Handlers | Registers a new search handler |
One common use case is to index additional data into ES and make it searchable. The following example shows which components are required to add new data sources to ES and keep it up to date. After data is indexed, the product number search will be extended to select additional data. The example indexes Shopware blog entries. To index additional data, the following class implementations are required: 1. DataIndexerInterface 2. MappingInterface 3. SynchronizerInterface 4. A class to record Doctrine events and write them into the backlog
You can find an installable ZIP package of this plugin here.
The plugin service.xml looks as follows:
<container xmlns="http://symfony.com/schema/dic/services"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://symfony.com/schema/dic/services http://symfony.com/schema/dic/services/services-1.0.xsd">
<services>
<service id="swag_es_blog.bundle_blog_provider" class="SwagESBlog\Bundle\ESIndexingBundle\BlogProvider">
<argument type="service" id="dbal_connection"/>
</service>
<!-- Add DataIndexer 'BlogDataIndexer' -->
<service id="swag_es_blog.bundle.indexer" class="SwagESBlog\Bundle\ESIndexingBundle\BlogDataIndexer">
<argument type="service" id="dbal_connection"/>
<argument type="service" id="shopware_elastic_search.client"/>
<argument type="service" id="swag_es_blog.bundle_blog_provider"/>
<!-- /.../engine/Shopware/Bundle/ESIndexingBundle/DependencyInjection/CompilerPass/DataIndexerCompilerPass.php -->
<tag name="shopware_elastic_search.data_indexer"/>
</service>
<!-- Add search mapping 'BlogMapping' -->
<service id="swag_es_blog.bundle.mapping" class="SwagESBlog\Bundle\ESIndexingBundle\BlogMapping">
<argument type="service" id="shopware_elastic_search.field_mapping"/>
<!-- /.../engine/Shopware/Bundle/ESIndexingBundle/DependencyInjection/CompilerPass/MappingCompilerPass.php -->
<tag name="shopware_elastic_search.mapping"/>
</service>
<!-- Add settings 'BlogSettings' -->
<service id="swag_es_blog.bundle.settings" class="SwagESBlog\Bundle\ESIndexingBundle\BlogSettings">
<!-- /.../engine/Shopware/Bundle/ESIndexingBundle/DependencyInjection/CompilerPass/SettingsCompilerPass.php -->
<tag name="shopware_elastic_search.settings"/>
</service>
<!-- Add synchronizer 'BlogSynchronizer' -->
<service id="swag_es_blog.bundle.synchronizer" class="SwagESBlog\Bundle\ESIndexingBundle\BlogSynchronizer">
<argument type="service" id="swag_es_blog.bundle.indexer"/>
<argument type="service" id="dbal_connection"/>
<!-- /.../engine/Shopware/Bundle/ESIndexingBundle/DependencyInjection/CompilerPass/SynchronizerCompilerPass.php -->
<tag name="shopware_elastic_search.synchronizer"/>
</service>
<!-- Add search 'BlogSearch' -->
<service id="swag_es_blog.search_bundle.search" class="SwagESBlog\Bundle\SearchBundleES\BlogSearch">
<argument type="service" id="shopware_elastic_search.client"/>
<argument type="service" id="shopware_search.product_search"/>
<argument type="service" id="shopware_elastic_search.index_factory"/>
<!-- /.../engine/Shopware/Bundle/SearchBundleES/DependencyInjection/CompilerPass/SearchHandlerCompilerPass.php -->
<tag name="shopware_search_es.search_handler"/>
</service>
<!-- Add doctrine event subscriber 'ORMBacklogSubscriber.php' -->
<service id="user_listener" class="SwagESBlog\Subscriber\ORMBacklogSubscriber">
<argument type="service" id="service_container"/>
<!-- /.../engine/Shopware/Components/DependencyInjection/Compiler/DoctrineEventSubscriberCompilerPass.php -->
<tag name="doctrine.event_subscriber"/>
</service>
</services>
</container>
The service file contains only the events needed to register the new services and subscriber. The following classes are initialized and registered:
| Class | Description |
|---|---|
| BlogMapping | Defines how a blog data structure looks like |
| BlogDataIndexer | Populates the blog data into the ES indices |
| BlogProvider | Helper class which provides data for blog entries |
| ORMBacklogSubscriber | Traces Doctrine events for the blog entity and insert changes into the backlog queue |
| BlogSynchronizer | Handles backlog queue to synchronize blog entities which are added by the ORMBacklogSubscriber |
To index data into ES, the BlogMapping, BlogDataIndexer and BlogProvider are required.
Before data can be indexed, a data mapping has to be created. This is defined in the BlogMapping class, which looks as follows:
<?php
namespace SwagESBlog\Bundle\ESIndexingBundle;
use Shopware\Bundle\ESIndexingBundle\FieldMappingInterface;
use Shopware\Bundle\ESIndexingBundle\MappingInterface;
use Shopware\Bundle\StoreFrontBundle\Struct\Shop;
class BlogMapping implements MappingInterface
{
/**
* @var FieldMappingInterface
*/
private $fieldMapping;
/**
* @param FieldMappingInterface $fieldMapping
*/
public function __construct(FieldMappingInterface $fieldMapping)
{
$this->fieldMapping = $fieldMapping;
}
/**
* @return string
*/
public function getType()
{
return 'blog';
}
/**
* @param Shop $shop
* @return array
*/
public function get(Shop $shop)
{
return [
'properties' => [
'id' => ['type' => 'long'],
'title' => $this->fieldMapping->getLanguageField($shop),
'shortDescription' => $this->fieldMapping->getLanguageField($shop),
'longDescription' => $this->fieldMapping->getLanguageField($shop),
'metaTitle' => $this->fieldMapping->getLanguageField($shop),
'metaKeywords' => $this->fieldMapping->getLanguageField($shop),
'metaDescription' => $this->fieldMapping->getLanguageField($shop)
]
];
}
}
BlogMapping uses the FieldMappingInterface to get definitions for language fields.
It returns a string field definition with sub-fields for configured shop analyzers.
A language field is only required if a search term for this field has to be analyzed for different shop languages.
For fields which shouldn't be searchable, it is more useful to define a simple string field. Example for shop with english locale:
[
[type] => string
[fields] => [
[english_analyzer] => [
[type] => string
[analyzer] => english
]
]
]
The english_analyzer field uses, at indexing and search time, a pre-configured english analyzer of ES.
The getType function defines the unique name for the data type, in this example blog.
After the data mapping is defined, the data can be indexed using the BlogDataIndexer, which looks as follows:
<?php
namespace SwagESBlog\Bundle\ESIndexingBundle;
use Doctrine\DBAL\Connection;
use Elasticsearch\Client;
use Shopware\Bundle\ESIndexingBundle\Console\ProgressHelperInterface;
use Shopware\Bundle\ESIndexingBundle\DataIndexerInterface;
use Shopware\Bundle\ESIndexingBundle\Struct\ShopIndex;
use Shopware\Bundle\StoreFrontBundle\Struct\Shop;
class BlogDataIndexer implements DataIndexerInterface
{
/**
* @var Connection
*/
private $connection;
/**
* @var Client
*/
private $client;
/**
* @var BlogProvider
*/
private $provider;
/**
* @param Connection $connection
* @param Client $client
* @param BlogProvider $provider
*/
public function __construct(
Connection $connection,
Client $client,
BlogProvider $provider
) {
$this->connection = $connection;
$this->client = $client;
$this->provider = $provider;
}
/**
* @param ShopIndex $index
* @param ProgressHelperInterface $progress
*/
public function populate(ShopIndex $index, ProgressHelperInterface $progress)
{
$ids = $this->getBlogIds($index->getShop());
$progress->start(count($ids), 'Indexing blog');
$chunks = array_chunk($ids, 100);
foreach ($chunks as $chunk) {
$this->index($index, $chunk);
$progress->advance(100);
}
$progress->finish();
}
/**
* @param ShopIndex $index
* @param int[] $ids
*/
public function index(ShopIndex $index, $ids)
{
if (empty($ids)) {
return;
}
$blog = $this->provider->get($ids);
$remove = array_diff($ids, array_keys($blog));
$documents = [];
foreach ($blog as $row) {
$documents[] = ['index' => ['_id' => $row->getId()]];
$documents[] = $row;
}
foreach ($remove as $id) {
$documents[] = ['delete' => ['_id' => $id]];
}
if (empty($documents)) {
return;
}
$this->client->bulk([
'index' => $index->getName(),
'type' => 'blog',
'body' => $documents
]);
}
private function getBlogIds(Shop $shop)
{
$query = $this->connection->createQueryBuilder();
$query->select('blog.id')
->from('s_blog', 'blog')
->innerJoin('blog', 's_categories', 'category', 'category.id = blog.category_id AND category.path LIKE :path')
->setParameter(':path', '%|'.(int)$shop->getCategory()->getId().'|%');
return $query->execute()->fetchAll(\PDO::FETCH_COLUMN);
}
}
The populate function is responsible for loading all blog entries into ES for the provided shop.
Since the shop indexer doesn't know how to iterate the DataIndexer rows, the iteration has to be inside the populate function.
A progress can be displayed using the provided ProgressHelperInterface.
First, the function selects all blog ids for the provided shop and passes them into the index function.
To separate data indexing and data loading, the BlogIndexer loads blog data using his own BlogProvider.
$blog = $this->provider->get($ids);
ES allows executing multiple task types, such as delete, update and insert, inside a single request, using the Bulk API, which expects the following parameters:
- index (name of the ES index)
- type (mapping type, blog in this case)
- body (documents to index)
To index a document using the bulk API, two array elements must be sent:
$this->client->bulk([
'index' => $index->getName(),
'type' => 'blog',
'body' => [
['index' => ['_id' => 1]],
['name' => 'First blog row', 'title' => '...', ...]
]
]);
Documents provided using the bulk API must be json serializable.
In this case, the provider returns a BlogStruct, which implements the JsonSerializable interface.
The indexing process can be started using the sw:es:index:populate console command:
$ php bin/console sw:es:index:populate
## Indexing shop Deutsch ##
Indexing blog
3/3 [============================] 100% 1 sec/1 sec
Indexing properties
5/5 [============================] 100% 1 sec/1 sec
Indexing products
197/197 [============================] 100% 1 sec/1 sec
## Indexing shop English ##
Indexing blog
3/3 [============================] 100% 1 sec/1 sec
Indexing properties
5/5 [============================] 100% 1 sec/1 sec
Indexing products
107/107 [============================] 100% 1 sec/1 sec
To keep the blog entries up to date, it is required to synchronize the blog data when changes are made.
By default, Shopware traces changes using doctrine events.
The first step to synchronize blog entries is registering the ORMBacklogSubscriber service. It subscribes to Doctrine's onFlush and postFlush events, in order to trace data changes.
<?php
namespace SwagESBlog\Subscriber;
use Doctrine\Common\EventSubscriber;
use Doctrine\ORM\Event\OnFlushEventArgs;
use Doctrine\ORM\Event\PostFlushEventArgs;
use Doctrine\ORM\Events;
use Shopware\Bundle\ESIndexingBundle\Struct\Backlog;
use Shopware\Components\DependencyInjection\Container;
use Shopware\Components\Model\ModelEntity;
use Shopware\Components\Model\ModelManager;
use Shopware\Models\Blog\Blog as BlogModel;
class ORMBacklogSubscriber implements EventSubscriber
{
const EVENT_BLOG_DELETED = 'blog_deleted';
const EVENT_BLOG_INSERTED = 'blog_inserted';
const EVENT_BLOG_UPDATED = 'blog_updated';
/**
* @var Backlog[]
*/
private $queue = [];
/**
* @var array
*/
private $inserts;
/**
* @var bool
*/
private $eventRegistered = false;
/**
* @var Container
*/
private $container;
/**
* @param Container $container
*/
public function __construct(Container $container)
{
$this->container = $container;
}
/**
* {@inheritdoc}
*/
public function getSubscribedEvents()
{
return [
Events::onFlush,
Events::postFlush
];
}
/**
* @param OnFlushEventArgs $eventArgs
*/
public function onFlush(OnFlushEventArgs $eventArgs)
{
/** @var $em ModelManager */
$em = $eventArgs->getEntityManager();
$uow = $em->getUnitOfWork();
// Entity deletions
foreach ($uow->getScheduledEntityDeletions() as $entity) {
$backlog = $this->getDeleteBacklog($entity);
if (!$backlog) {
continue;
}
$this->queue[] = $backlog;
}
// Entity Insertions
foreach ($uow->getScheduledEntityInsertions() as $entity) {
$this->inserts[] = $entity;
}
// Entity updates
foreach ($uow->getScheduledEntityUpdates() as $entity) {
$backlog = $this->getUpdateBacklog($entity);
if (!$backlog) {
continue;
}
$this->queue[] = $backlog;
}
}
/**
* @param PostFlushEventArgs $eventArgs
*/
public function postFlush(PostFlushEventArgs $eventArgs)
{
foreach ($this->inserts as $entity) {
$backlog = $this->getInsertBacklog($entity);
if (!$backlog) {
continue;
}
$this->queue[] = $backlog;
}
$this->inserts = [];
$this->registerShutdownListener();
}
private function registerShutdownListener()
{
if ($this->eventRegistered) {
return;
}
$this->eventRegistered = true;
$this->container->get('events')->addListener(
'Enlight_Controller_Front_DispatchLoopShutdown',
function () {
$this->processQueue();
}
);
}
private function processQueue()
{
if (empty($this->queue)) {
return;
}
$this->container->get('shopware_elastic_search.backlog_processor')->add($this->queue);
$this->queue = [];
}
/**
* @param ModelEntity $entity
* @return Backlog
*/
private function getDeleteBacklog($entity)
{
switch (true) {
case ($entity instanceof BlogModel):
return new Backlog(self::EVENT_BLOG_DELETED, ['id' => $entity->getId()]);
}
}
/**
* @param ModelEntity $entity
* @return Backlog
*/
private function getInsertBacklog($entity)
{
switch (true) {
case ($entity instanceof BlogModel):
return new Backlog(self::EVENT_BLOG_INSERTED, ['id' => $entity->getId()]);
}
}
/**
* @param ModelEntity $entity
* @return Backlog
*/
private function getUpdateBacklog($entity)
{
switch (true) {
case ($entity instanceof BlogModel):
return new Backlog(self::EVENT_BLOG_UPDATED, ['id' => $entity->getId()]);
}
}
}
It separates between update, delete and insert actions of the Shopware blog model.
This class can be used as reference for other implementations.
To prevent database operation inside a Doctrine flush event, the postFlush function registers a dynamic event listener for the Enlight_Controller_Front_DispatchLoopShutdown event, which inserts new backlogs entries at the end of the request.
New backlogs can be easily added using the shopware_elastic_search.backlog_processor service. A Backlog struct contains the following data structure:
- event > which can be defined manually
- payload > data which will be saved as json string
Changes to blog entries are traced and stored in the s_es_backlog table.
The synchronisation process will take place when the sw:es:backlog:sync command is called.
To handle the backlog queue, it is required to register an additional SynchronizerInterface, which looks as follows:
<?php
namespace SwagESBlog\Bundle\ESIndexingBundle;
use Doctrine\DBAL\Connection;
use Shopware\Bundle\ESIndexingBundle\Struct\ShopIndex;
use Shopware\Bundle\ESIndexingBundle\SynchronizerInterface;
use Shopware\Bundle\StoreFrontBundle\Struct\Shop;
use SwagESBlog\Subscriber\ORMBacklogSubscriber;
class BlogSynchronizer implements SynchronizerInterface
{
/**
* @var BlogDataIndexer
*/
private $indexer;
/**
* @var Connection
*/
private $connection;
/**
* @param BlogDataIndexer $indexer
* @param Connection $connection
*/
public function __construct(BlogDataIndexer $indexer, Connection $connection)
{
$this->indexer = $indexer;
$this->connection = $connection;
}
public function synchronize(ShopIndex $shopIndex, $backlogs)
{
$ids = [];
foreach ($backlogs as $backlog) {
switch ($backlog->getEvent()) {
case ORMBacklogSubscriber::EVENT_BLOG_UPDATED:
case ORMBacklogSubscriber::EVENT_BLOG_DELETED:
case ORMBacklogSubscriber::EVENT_BLOG_INSERTED:
$payload = $backlog->getPayload();
$ids[] = $payload['id'];
break;
default:
continue;
}
}
$blogIds = $this->filterShopBlog($shopIndex->getShop(), $ids);
if (empty($blogIds)) {
return;
}
$this->indexer->index($shopIndex, $blogIds);
}
private function filterShopBlog(Shop $shop, $ids)
{
$query = $this->connection->createQueryBuilder();
$query->select('blog.id')
->from('s_blog', 'blog')
->innerJoin('blog', 's_categories', 'category', 'category.id = blog.category_id AND category.path LIKE :path')
->andWhere('blog.id IN (:ids)')
->setParameter(':path', '%|' . (int)$shop->getCategory()->getId() . '|%')
->setParameter(':ids', $ids, Connection::PARAM_INT_ARRAY);
return $query->execute()->fetchAll(\PDO::FETCH_COLUMN);
}
}
The BlogSynchronizer implements the SynchronizerInterface, requiring a synchronize method implementation, which has a ShopIndex parameter to define which ES index has to be synchronized, and an array of Backlog structs which has to be processed.
A Backlog struct contains the saved payload, which contains, in this case, the blog id. After all blog ids are collected, the implementation provides them, with the ShopIndex, to his own BlogIndexer to index these ids again.
By filtering the selected blog ids using the filterShopBlog, the BlogIndexer gets only ids for the provided shop.
This logic can be placed in the BlogIndexer too, which is a detail of each implementation.
Now the plugin has to decorate the ProductSearch to search for blog entries.
This is possible using following xml code:
<!-- Decorates product search with 'BlogSearch' -->
<service id="swag_es_blog.search_bundle.search" class="SwagESBlog\Bundle\SearchBundleES\BlogSearch"
decorates="shopware_search.product_search"
public="false">
<argument type="service" id="shopware_elastic_search.client"/>
<argument type="service" id="swag_es_blog.search_bundle.search.inner"/>
<argument type="service" id="shopware_elastic_search.index_factory"/>
</service>
The BlogSearch looks as follows:
<?php
namespace SwagESBlog\Bundle\SearchBundleES;
use Elasticsearch\Client;
use ONGR\ElasticsearchDSL\Query\MultiMatchQuery;
use ONGR\ElasticsearchDSL\Search;
use Shopware\Bundle\ESIndexingBundle\IndexFactoryInterface;
use Shopware\Bundle\SearchBundle\Condition\SearchTermCondition;
use Shopware\Bundle\SearchBundle\Criteria;
use Shopware\Bundle\SearchBundle\ProductSearchInterface;
use Shopware\Bundle\StoreFrontBundle\Struct;
use SwagESBlog\Bundle\ESIndexingBundle\Struct\Blog;
class BlogSearch implements ProductSearchInterface
{
/**
* @var ProductSearchInterface
*/
private $coreService;
/**
* @var Client
*/
private $client;
/**
* @var IndexFactoryInterface
*/
private $indexFactory;
/**
* @param Client $client
* @param ProductSearchInterface $coreService
* @param IndexFactoryInterface $indexFactory
*/
public function __construct(
Client $client,
ProductSearchInterface $coreService,
IndexFactoryInterface $indexFactory
) {
$this->coreService = $coreService;
$this->client = $client;
$this->indexFactory = $indexFactory;
}
public function search(Criteria $criteria, Struct\ProductContextInterface $context)
{
$result = $this->coreService->search($criteria, $context);
if ($criteria->hasCondition('search')) {
$blog = $this->searchBlog($criteria, $context);
$result->addAttribute(
'swag_elastic_search',
new Struct\Attribute(['blog' => $blog])
);
}
return $result;
}
private function searchBlog(Criteria $criteria, Struct\ProductContextInterface $context)
{
/**@var $condition SearchTermCondition*/
$condition = $criteria->getCondition('search');
$query = $this->createMultiMatchQuery($condition);
$search = new Search();
$search->addQuery($query);
$search->setFrom(0)->setSize(5);
$index = $this->indexFactory->createShopIndex($context->getShop());
$params = [
'index' => $index->getName(),
'type' => 'blog',
'body' => $search->toArray()
];
$raw = $this->client->search($params);
return $this->createBlogStructs($raw);
}
/**
* @param SearchTermCondition $condition
* @return MultiMatchQuery
*/
private function createMultiMatchQuery(SearchTermCondition $condition)
{
return new MultiMatchQuery(
['title', 'shortDescription', 'longDescription'],
$condition->getTerm(),
['operator' => 'AND']
);
}
/**
* @param $raw
* @return array
*/
private function createBlogStructs($raw)
{
$result = [];
foreach ($raw['hits']['hits'] as $hit) {
$source = $hit['_source'];
$blog = new Blog($source['id'], $source['title']);
$blog->setShortDescription($source['shortDescription']);
$blog->setLongDescription($source['longDescription']);
$blog->setMetaTitle($source['metaTitle']);
$blog->setMetaKeywords($source['metaKeywords']);
$blog->setMetaDescription($source['metaDescription']);
$result[] = $blog;
}
return $result;
}
}
By implementing the ProductSearchInterface, it is required to implement a search function with a Criteria and ShopContextInterface parameters.
public function search(Criteria $criteria, Struct\ProductContextInterface $context)
This function is the only public API of this class and has to return a ProductSearchResult.
The product search should be executed using the original ProductSearchService, therefore the function calls the search function on the decorated service to get access on the original search result.
$result = $this->coreService->search($criteria, $context);
After the product search has been executed, it is required to check if the provided Criteria class contains a search condition.
If the criteria contains no search condition, the criteria class contains only filter parameters like categoryId: 10; filterValues: [1,2]; .. which are used to define products list, like category listings or sliders, and the function has to return, at this point, the original product search result.
if (!$criteria->hasCondition('search')) {
return $result;
}
In case the criteria contains a search condition, the searchBlog function can be called to search for blog entries which matches the provided search term.
The searchBlog function first builds a MultiMatchQuery for all relevant search fields:
/**@var $condition SearchTermCondition*/
$condition = $criteria->getCondition('search');
$query = new MultiMatchQuery(
['title', 'shortDescription', 'longDescription'],
$condition->getTerm(),
['operator' => 'AND']
);
To build the required search body structure, Shopware uses the ONGR\ElasticsearchDSL\Search class, which allows building ES search queries:
$search = new Search();
$search->addQuery($query);
$search->setFrom(0)->setSize(5);
ES searches are executed using official client Elasticsearch\Client.
$index = $this->indexFactory->createShopIndex($context->getShop());
$raw = $this->client->search([
'index' => $index->getName(),
'type' => 'blog',
'body' => $search->toArray()
]);
The indexFactory is used to get the index name based on the current shop of the provided context.
As result, the client returns an array with all ES information of the search request:
[
[took] => 1
[timed_out] =>
[_shards] => [
[total] => 5
[successful] => 5
[failed] => 0
]
[hits] => [
[total] => 1
[max_score] => 1.0521741
[hits] =>[
[0] => [
[_index] => sw_shop2_20150707155554
[_type] => blog
[_id] => 6
[_score] => 1.0521741
[_source] => [
[id] => 6
[title] => The summer will be colorful
[shortDescription] => This summer is going to be colorful. Brightly colored clothes are the must-have for every style-conscious woman.
[longDescription] => This summer is going to be colorful. Brightly colored clothes are the must-have for every style-conscious woman. Whether lemon-yellow top, grass-green chinos or pink clutch – with these colors you will definitely be an eye catcher. And this year we even go one step further.
[metaTitle] =>
[metaKeywords] =>
[metaDescription] =>
]
]
]
]
]
This data will be hydrated and converted to Blog structs using the createBlogStructs function:
private function createBlogStructs($raw)
{
$result = [];
foreach ($raw['hits']['hits'] as $hit) {
$source = $hit['_source'];
$blog = new Blog($source['id'], $source['title']);
//...
$result[] = $blog;
}
return $result;
}
The found blog structs will be assigned as an Attribute struct to the original product search result:
$blog = $this->searchBlog($criteria, $context);
$result->addAttribute(
'swag_blog_es_search',
new Struct\Attribute(['blog' => $blog])
);
The basic blog data should be indexed and searchable with the custom ES analyzer.
To add an additional analyzer, the SettingsInterface of the ESIndexingBundle can be used.
The plugin service.xml registers an additional service to add the new BlogSettings:
<!-- Add settings 'BlogSettings' -->
<service id="swag_es_blog.bundle.settings" class="SwagESBlog\Bundle\ESIndexingBundle\BlogSettings">
<!-- /.../engine/Shopware/Bundle/ESIndexingBundle/DependencyInjection/CompilerPass/SettingsCompilerPass.php -->
<tag name="shopware_elastic_search.settings"/>
</service>
The BlogSettings class contains the following source code:
<?php
namespace SwagESBlog\Bundle\ESIndexingBundle;
use Shopware\Bundle\ESIndexingBundle\SettingsInterface;
use Shopware\Bundle\StoreFrontBundle\Struct\Shop;
class BlogSettings implements SettingsInterface
{
/**
* @param Shop $shop
* @return array|null
*/
public function get(Shop $shop)
{
return [
'settings' => [
'analysis' => [
'analyzer' => [
'blog_analyzer' => [
'type' => 'custom',
'tokenizer' => 'standard',
'filter' => [
'lowercase',
]
]
]
]
]
];
}
}
The get function has to return a nested array which will be provided to the Elasticsearch\Client::putSettings API.
This blog_analyzer contains definitions for a new custom ES analyzer, which contains only a lowercase token filter, which means that all input data will be lowercase.
In case they were configured prior to indexing, Shopware provides a small tool to test analyzers.
sw:es:analyze [<shopId>] [<analyzer>] [<query>]
$ php bin/console sw:es:analyze 1 'blog_analyzer' 'Shopware AG'
+----------+-------+-----+------------+----------+
| Token | Start | End | Type | position |
+----------+-------+-----+------------+----------+
| shopware | 0 | 8 | <ALPHANUM> | 1 |
| ag | 9 | 11 | <ALPHANUM> | 2 |
+----------+-------+-----+------------+----------+
For more information about analyzers refer to Analysis API.
This analyzer can be used in the blog mapping as follows:
<?php
namespace SwagESBlog\Bundle\ESIndexingBundle;
use Shopware\Bundle\ESIndexingBundle\FieldMappingInterface;
use Shopware\Bundle\ESIndexingBundle\MappingInterface;
use Shopware\Bundle\StoreFrontBundle\Struct\Shop;
class BlogMapping implements MappingInterface
{
//...
public function get(Shop $shop)
{
return [
'properties' => [
'id' => ['type' => 'long'],
'title' => ['type' => 'string', 'analyzer' => 'blog_analyzer'],
//...
]
];
}
}
Another common use case is to extend the indexed product data with additional plugin data, to extend listing filtering or query optimization. The following example shows how to extend product data with additional fields.
You can find a installable ZIP package of this plugin here.
To extend the indexed product data for ES, the plugin has to decorate two services:
1. ProductMapping, which defines how the product data looks like
2. ProductProvider, which selects the data for ES
The plugin service.xml looks as follows:
<!-- Decorates productMapping -->
<service id="swag_es_product.decorator.es_product_mapping"
class="SwagESProduct\Bundle\ESIndexingBundle\ProductMapping"
decorates="shopware_elastic_search.product_mapping"
public="false">
<argument type="service" id="swag_es_product.decorator.es_product_mapping.inner"/>
</service>
<!-- Decorates productProvider -->
<service id="swag_es_product.decorator.es_product_provider"
class="SwagESProduct\Bundle\ESIndexingBundle\ProductProvider"
decorates="shopware_elastic_search.product_provider"
public="false">
<argument type="service" id="swag_es_product.decorator.es_product_provider.inner"/>
</service>
The new services has a dependency on the original service, otherwise the new service would override the original implementation.
The ProductMapping class looks as follows:
<?php
namespace SwagESProduct\ESIndexingBundle;
use Shopware\Bundle\ESIndexingBundle\MappingInterface;
use Shopware\Bundle\StoreFrontBundle\Struct\Shop;
class ProductMapping implements MappingInterface
{
/**
* @var MappingInterface
*/
private $coreMapping;
/**
* @param MappingInterface $coreMapping
*/
public function __construct(MappingInterface $coreMapping)
{
$this->coreMapping = $coreMapping;
}
/**
* @return string
*/
public function getType()
{
return $this->coreMapping->getType();
}
/**
* @param Shop $shop
* @return array
*/
public function get(Shop $shop)
{
$mapping = $this->coreMapping->get($shop);
$mapping['properties']['attributes']['properties']['swag_es_product'] = [
'properties' => [
'my_name' => ['type' => 'string']
]
];
return $mapping;
}
}
Product data can only be extended with Attribute structs, therefore the function adds a new product attribute swag_es_product with a field my_name.
This field is filled over the decorated ProductProvider class and contains the product name and the product keywords:
<?php
namespace SwagESProduct\ESIndexingBundle;
use Shopware\Bundle\ESIndexingBundle\Product\ProductProviderInterface;
use Shopware\Bundle\ESIndexingBundle\Struct\Product;
use Shopware\Bundle\StoreFrontBundle\Struct\Attribute;
use Shopware\Bundle\StoreFrontBundle\Struct\Shop;
class ProductProvider implements ProductProviderInterface
{
/**
* @var ProductProviderInterface
*/
private $coreService;
/**
* @param ProductProviderInterface $coreService
*/
public function __construct(ProductProviderInterface $coreService)
{
$this->coreService = $coreService;
}
/**
* @param Shop $shop
* @param string[] $numbers
* @return Product[]
*/
public function get(Shop $shop, $numbers)
{
$products = $this->coreService->get($shop, $numbers);
foreach ($products as $product) {
$attribute = new Attribute(['my_name' => $product->getName() . ' / ' . $product->getKeywords()]);
$product->addAttribute('swag_es_product', $attribute);
}
return $products;
}
}
Now it is time to extend the product search query, so it handles the new my_name field.
The product search query for ES is build using the SearchTermQueryBuilderInterface of the SearchBundleES, which can be decorated like all other services of the DI container:
<!-- Decorates searchTermQueryBuilder -->
<service id="swag_es_product.decorator.es_search_query_builder"
class="SwagESProduct\Bundle\SearchBundleES\SearchTermQueryBuilder"
decorates="shopware_search_es.search_term_query_builder"
public="false">
<argument type="service" id="swag_es_product.decorator.es_search_query_builder.inner"/>
</service>
The SearchTermQueryBuilder contains only one buildQuery function that returns a ONGR\ElasticsearchDSL\Query\BoolQuery, which can contain different sub queries.
The new SearchTermQueryBuilder contains the following source code:
<?php
namespace SwagESProduct\Bundle\SearchBundleES;
use ONGR\ElasticsearchDSL\Query\BoolQuery;
use ONGR\ElasticsearchDSL\Query\MultiMatchQuery;
use Shopware\Bundle\SearchBundleES\SearchTermQueryBuilderInterface;
use Shopware\Bundle\StoreFrontBundle\Struct\ShopContextInterface;
class SearchTermQueryBuilder implements SearchTermQueryBuilderInterface
{
/**
* @var SearchTermQueryBuilderInterface
*/
private $decoratedQueryBuilder;
/**
* @param SearchTermQueryBuilderInterface $decoratedQueryBuilder
*/
public function __construct(SearchTermQueryBuilderInterface $decoratedQueryBuilder)
{
$this->decoratedQueryBuilder = $decoratedQueryBuilder;
}
/**
* @param ShopContextInterface $context
* @param $term
* @return BoolQuery
*/
public function buildQuery(ShopContextInterface $context, $term)
{
$query = $this->decoratedQueryBuilder->buildQuery($context, $term);
$matchQuery = new MultiMatchQuery(
['attributes.swag_es_product.my_name'],
$term
);
$query->add($matchQuery, BoolQuery::SHOULD);
return $query;
}
}
The following dump shows which queries are returned by the Shopware core's SearchTermQueryBuilder:
ONGR\ElasticsearchDSL\Query\BoolQuery Object
(
[container:ONGR\ElasticsearchDSL\Query\BoolQuery:private] => Array
(
[should] => Array
(
[0] => Array
(
[multi_match] => Array
(
[fields] => Array
(
[0] => name^7
[1] => name.*_analyzer^7
[2] => keywords^5
[3] => keywords.*_analyzer^5
[4] => manufacturer.name^3
[5] => manufacturer.name.*_analyzer^3
[6] => shortDescription
[7] => shortDescription.*_analyzer
)
[query] => Spachtel
[type] => best_fields
[minimum_should_match] => 50%
[tie_breaker] => 0.3
)
)
[1] => Array
(
[multi_match] => Array
(
[fields] => Array
(
[0] => number
[1] => name
)
[query] => Spachtel
[type] => phrase_prefix
[max_expansions] => 2
)
)
)
)
[parameters:ONGR\ElasticsearchDSL\Query\BoolQuery:private] => Array
(
[minimum_should_match] => 1
)
)
In this case, the query contains two MultiMatchQuery sub queries as a SHOULD query.
In addition, the query contains a minimum_should_match parameter with a value of 1, which means that one of the queries has to match.
The new SearchTermQueryBuilder now adds an additional MultiMatchQuery for the new field my_name which is stored in attributes.properties.swag_es_product:
$matchQuery = new MultiMatchQuery(
['attributes.swag_es_product.my_name'],
$term
);
$query->add($matchQuery, BoolQuery::SHOULD);
After the query has been added, a dump of the original query looks as follows:
ONGR\ElasticsearchDSL\Query\BoolQuery Object
(
[container:ONGR\ElasticsearchDSL\Query\BoolQuery:private] => Array
(
[should] => Array
(
[0] => Array
(
[multi_match] => Array
(
[fields] => Array
(
[0] => name^7
[1] => name.*_analyzer^7
[2] => keywords^5
[3] => keywords.*_analyzer^5
[4] => manufacturer.name^3
[5] => manufacturer.name.*_analyzer^3
[6] => shortDescription
[7] => shortDescription.*_analyzer
)
[query] => Spachtel
[type] => best_fields
[minimum_should_match] => 50%
[tie_breaker] => 0.3
)
)
[1] => Array
(
[multi_match] => Array
(
[fields] => Array
(
[0] => number
[1] => name
)
[query] => Spachtel
[type] => phrase_prefix
[max_expansions] => 2
)
)
[2] => Array
(
[multi_match] => Array
(
[fields] => Array
(
[0] => attributes.swag_es_product.my_name
)
[query] => Spachtel
)
)
)
)
[parameters:ONGR\ElasticsearchDSL\Query\BoolQuery:private] => Array
(
[minimum_should_match] => 1
)
)
In the following example, the plugin adds a new Facet, Sorting and Condition for the product listing, which accesses the product sales field.
This example can be used for each other field which is indexed for products.
First, the plugin has to register his own criteria parts (Facet, Sorting and Condition) with a CriteriaRequestHandler.
The criteria part classes have no dependencies on any search implementation like DBAL or ES.
They are defined as abstract and only describe how the search should be executed.
The plugin bootstrap contains the additional source code:
<!-- Add criteria_request_handler -->
<service id="swag_es_product.search.criteria_request_handler"
class="SwagESProduct\Bundle\SearchBundle\CriteriaRequestHandler">
<tag name="criteria_request_handler"/>
</service>
<!-- Add shopware_search_es.search_handler -->
<service id="swag_es_product.es_search.sales_facet_handler"
class="SwagESProduct\Bundle\SearchBundleES\SalesFacetHandler">
<tag name="shopware_search_es.search_handler"/>
</service>
<service id="swag_es_product.es_search.sales_condition_handler"
class="SwagESProduct\Bundle\SearchBundleES\SalesConditionHandler">
<tag name="shopware_search_es.search_handler"/>
</service>
<service id="swag_es_product.es_search.sales_sorting_handler"
class="SwagESProduct\Bundle\SearchBundleES\SalesSortingHandler">
<tag name="shopware_search_es.search_handler"/>
</service>
The criteria_request_handler tag allows you to register an additional CriteriaRequestHandler.
By using the shopware_search_es.search_handler tag it is possible to register additional handlers for the SearchBundleES.
Each criteria part should have its own handler class.
First, the plugin has to add the new criteria parts into the criteria for listings.
This will be handled in the CriteriaRequestHandler, which is called if a Criteria class must be generated with the request parameters:
<?php
namespace SwagESProduct\Bundle\SearchBundle;
use Enlight_Controller_Request_RequestHttp as Request;
use Shopware\Bundle\SearchBundle\Criteria;
use Shopware\Bundle\SearchBundle\CriteriaRequestHandlerInterface;
use Shopware\Bundle\StoreFrontBundle\Struct\ShopContextInterface;
class CriteriaRequestHandler implements CriteriaRequestHandlerInterface
{
public function handleRequest(
Request $request,
Criteria $criteria,
ShopContextInterface $context
) {
$criteria->addFacet(new SalesFacet());
$minSales = $request->getParam('minSales', null);
$maxSales = $request->getParam('maxSales', null);
if ($minSales || $maxSales) {
$criteria->addCondition(
new SalesCondition($minSales, $maxSales)
);
}
if ($request->getParams('sSort') == 'sales') {
$criteria->resetSorting();
$criteria->addSorting(new SalesSorting());
}
}
}
This class adds the SalesFacet to the criteria and, if the request contains different parameters, the SalesCondition and the SalesSorting.
The SalesFacet, SalesCondition and SalesSorting class look as follows:
<?php
namespace SwagESProduct\Bundle\SearchBundle;
use Shopware\Bundle\SearchBundle\ConditionInterface;
class SalesCondition implements ConditionInterface
{
/**
* @var int
*/
private $min;
/**
* @var int
*/
private $max;
/**
* @param int $min
* @param int $max
*/
public function __construct($min, $max)
{
$this->min = $min;
$this->max = $max;
}
/**
* @return int
*/
public function getMin()
{
return $this->min;
}
/**
* @return int
*/
public function getMax()
{
return $this->max;
}
public function getName()
{
return 'swag_es_product_sales';
}
}
<?php
namespace SwagESProduct\Bundle\SearchBundle;
use Shopware\Bundle\SearchBundle\FacetInterface;
class SalesFacet implements FacetInterface
{
public function getName()
{
return 'swag_es_product_sales';
}
}
<?php
namespace SwagESProduct\Bundle\SearchBundle;
use Shopware\Bundle\SearchBundle\SortingInterface;
class SalesSorting implements SortingInterface
{
public function getName()
{
return 'swag_es_product_sales';
}
}
After the facet has been added to the criteria, the ShopwarePlugins\SwagESProduct\SearchBundleES\SalesFacetHandler will be implemented and looks as follows:
<?php
namespace SwagESProduct\Bundle\SearchBundleES;
use ONGR\ElasticsearchDSL\Aggregation\StatsAggregation;
use ONGR\ElasticsearchDSL\Search;
use Shopware\Bundle\SearchBundle\Criteria;
use Shopware\Bundle\SearchBundle\CriteriaPartInterface;
use Shopware\Bundle\SearchBundle\FacetResult\RangeFacetResult;
use Shopware\Bundle\SearchBundle\ProductNumberSearchResult;
use Shopware\Bundle\SearchBundleES\HandlerInterface;
use Shopware\Bundle\SearchBundleES\ResultHydratorInterface;
use Shopware\Bundle\StoreFrontBundle\Struct\ShopContextInterface;
use SwagESProduct\Bundle\SearchBundle\SalesCondition;
use SwagESProduct\Bundle\SearchBundle\SalesFacet;
class SalesFacetHandler implements HandlerInterface, ResultHydratorInterface
{
public function supports(CriteriaPartInterface $criteriaPart)
{
return ($criteriaPart instanceof SalesFacet);
}
public function handle(
CriteriaPartInterface $criteriaPart,
Criteria $criteria,
Search $search,
ShopContextInterface $context
) {
$statsAgg = new StatsAggregation('sales');
$statsAgg->setField('sales');
$search->addAggregation($statsAgg);
}
public function hydrate(
array $elasticResult,
ProductNumberSearchResult $result,
Criteria $criteria,
ShopContextInterface $context
) {
if (!isset($elasticResult['aggregations']['agg_sales'])) {
return;
}
$data = $elasticResult['aggregations']['agg_sales'];
$actives = $this->getActiveValues($criteria, $data);
$facetResult = new RangeFacetResult(
'swag_product_es_sales',
$criteria->hasCondition('swag_es_product_sales'),
'Sales',
$data['min'],
$data['max'],
$actives['min'],
$actives['max'],
'minSales',
'maxSales'
);
$result->addFacet($facetResult);
}
/**
* @param Criteria $criteria
* @param $data
* @return array
*/
private function getActiveValues(Criteria $criteria, $data)
{
$actives = [
'min' => $data['min'],
'max' => $data['max']
];
/** @var SalesCondition $condition */
if (!($condition = $criteria->getCondition('swag_es_product_sales'))) {
return $actives;
}
if ($condition->getMin()) {
$actives['min'] = $condition->getMin();
}
if ($condition->getMax()) {
$actives['max'] = $condition->getMax();
}
return $actives;
}
}
The first step is to implement a facet handler for ES.
The class has to implement the Shopware\Bundle\SearchBundleES\HandlerInterface interface, which requires the class to implement the supports and the handle functions.
In the support function, the new handler returns true if it can handle the provided CriteriaPartInterface class - in this case only if it is a SalesFacet:
public function supports(CriteriaPartInterface $criteriaPart)
{
return ($criteriaPart instanceof SalesFacet);
}
If the supports function returns true, the handle function will be called.
In this function it is possible to extend the provided ONGR\ElasticsearchDSL\Search class which is used for the search definition.
In this case the class adds a StatsAggregation to the field sales:
public function handle(
CriteriaPartInterface $criteriaPart,
Criteria $criteria,
Search $search,
ShopContextInterface $context
) {
$statsAgg = new StatsAggregation('sales');
$statsAgg->setField('sales');
$search->addAggregation($statsAgg);
}
Now the search generates an aggregation result for this field with the following data structure:
Array
(
[hits] => Array
(
[total] => 17
[max_score] =>
[hits] => Array
(...)
)
[aggregations] => Array
(
[agg_sales] => Array
(
[count] => 44
[min] => 2
[max] => 272
[avg] => 31.068181818182
[sum] => 1367
)
...
)
To display this data in the storefront, it is necessary to add a FacetResultInterface to the ProductSearchResult.
This can be done by implementing the Shopware\Bundle\SearchBundleES\ResultHydratorInterface interface, which requires the class to implement a hydrate function.
public function hydrate(
array $elasticResult,
ProductNumberSearchResult $result,
Criteria $criteria,
ShopContextInterface $context
) {
if (!isset($elasticResult['aggregations']['agg_sales'])) {
return;
}
$data = $elasticResult['aggregations']['agg_sales'];
$actives = $this->getActiveValues($criteria, $data);
$facetResult = new RangeFacetResult(
'swag_product_es_sales',
$criteria->hasCondition('swag_es_product_sales'),
'Sales',
$data['min'],
$data['max'],
$actives['min'],
$actives['max'],
'minSales',
'maxSales'
);
$result->addFacet($facetResult);
}
To prevent errors, the class first validates if the result is set before access:
if (!isset($elasticResult['aggregations']['agg_sales'])) {
return;
}
$data = $elasticResult['aggregations']['agg_sales'];
The next step is to generate a RangeFacetResult to display a range slider in the storefront, which allows customers to filter by conditions:
$facetResult = new RangeFacetResult(
'swag_product_es_sales',
$criteria->hasCondition('swag_es_product_sales'),
'Sales',
$data['min'],
$data['max'],
$actives['min'],
$actives['max'],
'minSales',
'maxSales'
);
The RangeFacetResult class has the following parameters:
- facetName ('swag_product_es_sales')
- active ($criteria->hasCondition('swag_es_product_sales'))
- label ('Sales')
- minValue ($data['min'])
- maxValue ($data['max'])
- activeMin ($actives['min'])
- activeMax ($actives['max'])
- request parameter for min value ('minSales')
- request parameter for max value ('maxSales')
The getActiveValues function validates which value should be used for activeMin and activeMax parameters,
by checking if the criteria class contains the SalesCondition.
If the customer uses the new range slider for sales, the request will contain the minSales and maxSales parameters,
which are handled by the new CriteriaRequestHandler by adding a SalesCondition with the provided data:
$minSales = $request->getParam('minSales', null);
$maxSales = $request->getParam('maxSales', null);
if ($minSales || $maxSales) {
$criteria->addCondition(
new SalesCondition($minSales, $maxSales)
);
}
This SalesCondition has its own SalesConditionHandler which looks as follows:
<?php
namespace SwagESProduct\Bundle\SearchBundleES;
use ONGR\ElasticsearchDSL\Filter\RangeFilter;
use ONGR\ElasticsearchDSL\Search;
use Shopware\Bundle\SearchBundle\Criteria;
use Shopware\Bundle\SearchBundle\CriteriaPartInterface;
use Shopware\Bundle\SearchBundleES\HandlerInterface;
use Shopware\Bundle\StoreFrontBundle\Struct\ShopContextInterface;
use SwagESProduct\Bundle\SearchBundle\SalesCondition;
class SalesConditionHandler implements HandlerInterface
{
public function supports(CriteriaPartInterface $criteriaPart)
{
return ($criteriaPart instanceof SalesCondition);
}
public function handle(
CriteriaPartInterface $criteriaPart,
Criteria $criteria,
Search $search,
ShopContextInterface $context
) {
$range = [];
/** @var SalesCondition $criteriaPart */
if ($criteriaPart->getMin() > 0) {
$range['gte'] = (int) $criteriaPart->getMin();
}
if ($criteriaPart->getMax() > 0) {
$range['lte'] = (int) $criteriaPart->getMax();
}
$filter = new RangeFilter('sales', $range);
if ($criteria->hasBaseCondition($criteriaPart->getName())) {
$search->addFilter($filter);
} else {
$search->addPostFilter($filter);
}
}
}
Like the SalesFacetHandler, the SalesConditionHandler implements the Shopware\Bundle\SearchBundleES\HandlerInterface to handle parts of the criteria.
The supports function returns true if the provided CriteriaPart is a SalesCondition.
public function supports(CriteriaPartInterface $criteriaPart)
{
return ($criteriaPart instanceof SalesCondition);
}
To filter the result with a from and to values, it is required to add a ONGR\ElasticsearchDSL\Filter\RangeFilter in the handle function:
public function handle(
CriteriaPartInterface $criteriaPart,
Criteria $criteria,
Search $search,
ShopContextInterface $context
) {
$range = [];
/** @var SalesCondition $criteriaPart */
if ($criteriaPart->getMin() > 0) {
$range['gte'] = (int) $criteriaPart->getMin();
}
if ($criteriaPart->getMax() > 0) {
$range['lte'] = (int) $criteriaPart->getMax();
}
$filter = new RangeFilter('sales', $range);
}
After the filter is created, it is required to check if the filter should be added as normal filter or as a post filter. 1. post filter > Filters the result after the aggregations calculated 2. normal filter > Filters the result before the aggregations calculated
This can be checked using the criteria object, by using the hasBaseCondition function.
If the criteriaPart is a BaseCondition, the filter has to be added as normal filter, otherwise as post filter.
if ($criteria->hasBaseCondition($criteriaPart->getName())) {
$search->addFilter($filter);
} else {
$search->addPostFilter($filter);
}
The CriteriaRequestHandler adds the SalesSorting class if the request parameter sSort is set to sales:
if ($request->getParams('sSort') == 'sales') {
$criteria->resetSorting();
$criteria->addSorting(new SalesSorting());
}
This is handled by the SalesSortingHandler:
<?php
namespace SwagESProduct\Bundle\SearchBundleES;
use ONGR\ElasticsearchDSL\Search;
use ONGR\ElasticsearchDSL\Sort\Sort;
use Shopware\Bundle\SearchBundle\Criteria;
use Shopware\Bundle\SearchBundle\CriteriaPartInterface;
use Shopware\Bundle\SearchBundleES\HandlerInterface;
use Shopware\Bundle\StoreFrontBundle\Struct\ShopContextInterface;
use SwagESProduct\Bundle\SearchBundle\SalesSorting;
class SalesSortingHandler implements HandlerInterface
{
public function supports(CriteriaPartInterface $criteriaPart)
{
return ($criteriaPart instanceof SalesSorting);
}
public function handle(
CriteriaPartInterface $criteriaPart,
Criteria $criteria,
Search $search,
ShopContextInterface $context
) {
$sort = new Sort('sales', 'desc');
$search->addSort($sort);
}
}
Like the other handlers, the SalesSortingHandler handles a CriteriaPart, so it implements the Shopware\Bundle\SearchBundleES\HandlerInterface.
To sort the search result, the ONGR\ElasticsearchDSL\Sort\Sort class can be added to the provided ONGR\ElasticsearchDSL\Search.
A new filter behaviour was implemented with shopware 5.3, now filters are only displayed if they can be combined with the user conditions that are currently in effect.
In the elastic search implementation the filter behavior is controlled by the condition handlers.
By adding a query as post filter, facets are not affected by other filters.
This behavior is checked via the Criteria->hasBaseCondition statement:
public function handle(
CriteriaPartInterface $criteriaPart,
Criteria $criteria,
Search $search,
ShopContextInterface $context
) {
if ($criteria->hasBaseCondition($criteriaPart->getName())) {
$search->addFilter(new TermQuery('active', 1));
} else {
$search->addPostFilter(new TermQuery('active', 1));
}
}
This behavior is now controlled in the \Shopware\Bundle\SearchBundleES\ProductNumberSearch.
To support the new filter mode, each condition handler has to implement the \Shopware\Bundle\SearchBundleES\PartialConditionHandlerInterface.
It is possible to implement this interface alongside the original \Shopware\Bundle\SearchBundleES\HandlerInterface.
namespace Shopware\Bundle\SearchBundleES;
if (!interface_exists('\Shopware\Bundle\SearchBundleES\PartialConditionHandlerInterface')) {
interface PartialConditionHandlerInterface { }
}
namespace Shopware\SwagBonusSystem\Bundle\SearchBundleES;
class BonusConditionHandler implements HandlerInterface, PartialConditionHandlerInterface
{
const ES_FIELD = 'attributes.bonus_system.has_bonus';
public function supports(CriteriaPartInterface $criteriaPart)
{
return ($criteriaPart instanceof BonusCondition);
}
public function handleFilter(
CriteriaPartInterface $criteriaPart,
Criteria $criteria,
Search $search,
ShopContextInterface $context
) {
$search->addFilter(
new TermQuery(self::ES_FIELD, 1)
);
}
public function handlePostFilter(
CriteriaPartInterface $criteriaPart,
Criteria $criteria,
Search $search,
ShopContextInterface $context
) {
$search->addPostFilter(new TermQuery(self::ES_FIELD, 1));
}
public function handle(
CriteriaPartInterface $criteriaPart,
Criteria $criteria,
Search $search,
ShopContextInterface $context
) {
if ($criteria->hasBaseCondition($criteriaPart->getName())) {
$this->handleFilter($criteriaPart, $criteria, $search, $context);
} else {
$this->handlePostFilter($criteriaPart, $criteria, $search, $context);
}
}
}
In Shopware 5.5.5 we added an elasticsearch logger which logs several ES requests and their responses.
In addition to that, you'll get an evaluation of the indexing process after each step by default.
The following options have been added to the index populate command for both, frontend and backend indexing:
| Option | Description |
|---|---|
| --no-evaluation | Disable the evaluation |
| --stop-on-error | Abort the indexing process if an error occurs |
An example indexing process that uses the --stop-on-error option could look like this:
$ sudo bin/console sw:es:index:populate --stop-on-error
## Indexing shop Deutsch ##
Indexing properties
5/5 [============================] 100% < 1 sec/< 1 sec
Evaluation:
Total: 5 items
Error: 0 items
Success: 5 items
Indexing products
In ConsoleEvaluationHelper.php line 183:
An error occured:
SW10002.3: mapper [calculatedPrices.EK_1.rule.unit.purchaseUnit] cannot be changed from type [long] to [float]
As already mentioned do these options work with both, the sw:es:index:populate and the sw:es:backend:index:populate command.
An occurred error can be viewed in detail via the system log backend view (Configuration > Logfile > System log) by selecting the correct ES logging file (something like es_production-2018-12-06.log).

The log files contain response and request data (in this order) for several ES requests.
In production environments only errors will be logged by default.
For development environments each request will be logged by default.
Please notice that if every ES request is logged, the log files will become accordingly large.
It is not recommended to log debug info in production environments.
You can customize the log level via your config.php from the default behaviour to fit your own needs (you can find the available log levels here):
// ...
'es' => [
// ...
'logger' => [
'level' => \Shopware\Components\Logger::DEBUG,
],
],
// ...