Customer - Search and Streams

This article explains how Customer Streams work and how to extend the customer stream and customer search module in Shopware. In most cases, the purpose of a plugin is to provide more conditions or add additional data to the search. The examples below describe how to achieve this.

Shopware 5.3 introduced a new feature called Customer Streams. Customer Streams are similar to Product Streams and allow you to create a subgroup of customers by using a combination of over 20 default filters (more can be added with plugins). Customer Streams can be used for:

  • Newsletters: Define the newsletter recipients
  • Shopping worlds: Only display shopping worlds if the user is part of the specified Customer Stream
  • Vouchers
  • Exports
  • Advanced Promotions Suite

Introduction

In order to create a new Customer Stream, all customers need to be analysed before. This process is required to ensure high performance. During the analysis, all data which is required for the filters is collected and saved into the s_customer_search_index database table.

If you create a new Customer Stream based on some filters, the module will check which of the previously analysed customers match these filters and adds them to your Customer Stream.

The mapping between customers and Customer Streams is saved in the s_customer_streams_mapping table. The stream definition (e.g. name, filters) is saved in the database table s_customer_streams. To improve the performance even further, only new customers are analysed.

While the process described above is very efficient, you have to keep in mind, that the customer related data can change (e.g. an order gets canceled). All theses changes are not reflected immediately since all the Customer Stream data is basically cached. Therefore you can not rely on a Customer Stream if you need to be sure that the data is still up to date.

Add own condition

Since the customer search is based on the same functions as the product search, the way of extending the search is pretty similar. The customer search gets a criteria object in which the different search conditions are summarized. The first step is to define your own condition:

<?php

namespace SwagCustomerSearchExtension\Bundle\CustomerSearchBundle;

use Shopware\Bundle\SearchBundle\ConditionInterface;

class ActiveCondition implements ConditionInterface
{
    /**
     * @var bool
     */
    protected $active;

    /**
     * @param bool $active
     */
    public function __construct($active)
    {
        $this->active = $active;
    }

    public function getName()
    {
        return 'ActiveCondition';
    }

    public function onlyActive()
    {
        return $this->active;
    }
}

The condition above only describes on an abstract level what is to be searched for. The actual processing of the condition happens in the corresponding implementation of the search. Currently the customer search in Shopware is only executed in SQL. The concept is based on SearchBundleDBAL and SearchBundleES. An actual handler class which handles this condition in SQL could look like this:

<?php

namespace SwagCustomerSearchExtension\Bundle\CustomerSearchBundleDBAL;

use Shopware\Bundle\CustomerSearchBundleDBAL\ConditionHandlerInterface;
use Shopware\Bundle\SearchBundle\ConditionInterface;
use Shopware\Bundle\SearchBundleDBAL\QueryBuilder;
use SwagCustomerSearchExtension\Bundle\CustomerSearchBundle\ActiveCondition;

class ActiveConditionHandler implements ConditionHandlerInterface
{
    public function supports(ConditionInterface $condition)
    {
        return $condition instanceof ActiveCondition;
    }

    public function handle(ConditionInterface $condition, QueryBuilder $query)
    {
        $query->andWhere('customer.active = :active');

        /** @var ActiveCondition $condition */
        $query->setParameter(':active', $condition->onlyActive());
    }
}

The handler can be registered with a compiler tag, named customer_search.condition_handler:

<?xml version="1.0" ?>

<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_customer_search_extension.active_condition_handler"
                 class="SwagCustomerSearchExtension\Bundle\CustomerSearchBundleDBAL\ActiveConditionHandler">

            <tag name="customer_search.condition_handler"/>
        </service>

    </services>
</container>

Backend Module extension

To support the condition in the backend, it is necessary to extend the customer module via ExtJS. The module can be extended over the PostDispatch event of the backend customer controller:

<?php

namespace SwagCustomerSearchExtension;

use Shopware\Components\Plugin;

class SwagCustomerSearchExtension extends Plugin
{
    public static function getSubscribedEvents()
    {
        return [
            'Enlight_Controller_Action_PostDispatch_Backend_Customer' => 'extendCustomerStream'
        ];
    }

    public function extendCustomerStream(\Enlight_Event_EventArgs $args)
    {
        /** @var \Shopware_Controllers_Backend_Customer $subject */
        $subject = $args->getSubject();

        $subject->View()->addTemplateDir(__DIR__ . '/Resources/views');

        $subject->View()->extendsTemplate('backend/customer/swag_customer_stream_extension.js');
    }
}

The extended swag_customer_stream_extension.js contains all overrides for the backend module:

// {block name="backend/customer/view/customer_stream/condition_panel"}

// {$smarty.block.parent}

Ext.define('Shopware.apps.Customer.SwagCustomerStreamExtension', {
    override: 'Shopware.apps.Customer.view.customer_stream.ConditionPanel',

    registerHandlers: function() {
        var me = this,
            //fetch original handlers
            handlers = me.callParent(arguments);

        //push own handler into
        handlers.push(Ext.create('Shopware.apps.Customer.swag_customer_stream_extension.ActiveCondition'));

        //return modified handlers array
        return handlers;
    }
});


//definition of your own condition
Ext.define('Shopware.apps.Customer.swag_customer_stream_extension.ActiveCondition', {

    getLabel: function() {
        return 'My active condition';
    },

    supports: function(conditionClass) {
        return (conditionClass == 'SwagCustomerSearchExtension\\Bundle\\CustomerSearchBundle\\ActiveCondition');
    },

    create: function(callback) {
        callback(this._create());
    },

    load: function(conditionClass, items, callback) {
        callback(this._create());
    },

    _create: function() {
        return {
            title: this.getLabel(),
            conditionClass: 'SwagCustomerSearchExtension\\Bundle\\CustomerSearchBundle\\ActiveCondition',
            items: [{
                xtype: 'checkbox',
                name: 'active',
                boxLabel: 'Activate for active customers, deactivate for inactive customers',
                inputValue: true,
                uncheckedValue: false
            }]
        };
    }
});

// {/block}

The first part hooks into the customer stream condition panel and registers the plugin condition. The second part contains the whole logic to handle the condition for load and create actions.

The create and load function have to return an object with the following data:

  • title - Used for the panel title
  • conditionClass - Used for class generation - Aside, used for singleton detection
  • items - Contains a list of parameters, which used for __construct call

Search Indexing

The CustomerSearchBundleDBAL uses an aggregated table which allows fast filtering and sorting, even on large data sets. This table is generated by the Shopware\Bundle\CustomerSearchBundleDBAL\Indexing\SearchIndexer class. If a plugin wants to filter and sort additional aggregated data, it can hook into the indexing process to collect additional data. The following services.xml shows how to decorate the customer_search.dbal.indexing.indexer.

<?xml version="1.0" ?>

<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_customer_search_extension.search_indexer"
                 class="SwagCustomerSearchExtension\Bundle\CustomerSearchBundleDBAL\SearchIndexer"
                 decorates="customer_search.dbal.indexing.indexer">

            <argument id="swag_customer_search_extension.search_indexer.inner" type="service"/>
            <argument id="dbal_connection" type="service"/>
        </service>
    </services>
</container>

Shopware expects that a class is found under this service name, which implements the interface SearchIndexerInterface:

<?php

namespace SwagCustomerSearchExtension\Bundle\CustomerSearchBundleDBAL;

use Doctrine\DBAL\Connection;
use Shopware\Bundle\CustomerSearchBundleDBAL\Indexing\SearchIndexerInterface;

class SearchIndexer implements SearchIndexerInterface
{
    /**
     * @var SearchIndexerInterface
     */
    private $coreIndexer;

    /**
     * @var Connection
     */
    private $connection;

    /**
     * @param SearchIndexerInterface $coreIndexer
     * @param Connection $connection
     */
    public function __construct(SearchIndexerInterface $coreIndexer, Connection $connection)
    {
        $this->coreIndexer = $coreIndexer;
        $this->connection = $connection;
    }

    public function populate(array $ids)
    {
        $this->coreIndexer->populate($ids);

        //fetch data
        $rows = $this->connection->createQueryBuilder()->execute()->fetchAll();

        //create prepared statement for fast inserts
        $statement = $this->connection->prepare("INSERT INTO test-table");

        //iterate rows and insert data
        foreach ($rows as $row) {
            $statement->execute($row);
        }
    }

    public function clearIndex()
    {
        $this->coreIndexer->clearIndex();
        $this->connection->executeUpdate("DELETE FROM test-table");
    }

    public function cleanupIndex()
    {
        $this->coreIndexer->cleanupIndex();
    }
}

The best way to index additional data is to aggregate the data beforehand and save it into a separate table, which is in a 1:1 relation to the original search_index table. The condition handler can access these indexed data quickly to allow a fluent experience. You download the example plugin above here.

REST API

We also offer a complete REST API for Customer Streams. For a detailed documentation click here.