Magento 2 Layered Navigation Custom Filters

Magento 2 Layered Navigation Custom Filters

Magento 2 Layered Navigation Custom Filters

Layered navigation part

In this post, I’m going to show you how to add a custom filter to layered navigation in Magento 2. At the time of writing this article, I have Magento 2.1.6.

Initial input: you need to add a filter for products on discount. For example, we have CatalogRule discounts (those that apply not only to the entire cart but to specific products if the conditions prescribed in the CatalogRule are fulfilled). We need to calculate the cost of each product taking into account all available discounts.

Initially, I intended to create eav_attribute which will contain a staged value for each product. However, I noticed that discounts differentiate not only depending on a website but also on a customer group. In other words, any CatalogRule rule can be applied only to registered users, or only guests, or any other customer group. Therefore, I decided to create an index table with the product ids and its discount for all customer groups. How to create index tables you’ll learn from the next article, as for now, let's suppose we already have the index table with the necessary data.

GREAT UPDATE! The extension Discount Filter is already in the store. Explore the demo to see how the discount filter works and improve your store navigation. 

Let’s start step-by-step

First, we need a new EAV attribute, which will have some peculiarity.
For this particular solution we create it with backend_type = decimal so that we can assign Range Filter to it (prices analogy, goods from 10 to 20 dollars). For presence in layered navigation we use attribute is_filterable = true. Other attributes are optional. There are no specific models nor classes, etc.

Next, let's take a look at how the filters are initialized based on attributes.

This is done via \Magento\Catalog\Model\Layer\FilterList:

Pay attention to the possible types of filters:

/**
    * @var string[]
    */
    protected $filterTypes = [
        self::CATEGORY_FILTER => 'Magento\Catalog\Model\Layer\Filter\Category',
        self::ATTRIBUTE_FILTER => 'Magento\Catalog\Model\Layer\Filter\Attribute',
        self::PRICE_FILTER => 'Magento\Catalog\Model\Layer\Filter\Price',
        self::DECIMAL_FILTER => 'Magento\Catalog\Model\Layer\Filter\Decimal',
    ];

If we open any of these filters we see that they all are all inherited from the abstract filter \Magento\Catalog\Model\Layer\Filter\AbstractFilter.

To make these filters working we select the two most important methods: apply and _getItemsData.

In the apply method, later we will add a discount filter. (Just the way the category filters are added). Here is the code of our method:

   /**
    * @param \Magento\Framework\App\RequestInterface $request
    *
    * @return $this
    */
    public function apply(\Magento\Framework\App\RequestInterface $request)
    {
        $filter = $request->getParam($this->getRequestVar());
        if (!$filter || is_array($filter)) {
          return $this;
        }
        if (!$this->validate($filter)) {
          return $this;
        }
        $productCollection = $this->getLayer()->getProductCollection();
        $productCollection->addFieldToFilter('group_discount', ['from' => $filter, 'to' => $filter + 9.9999]);
        $this->getLayer()->getState()->addFilter(
          $this->_createItem($this->getDiscountLabel($filter), $filter)
        );
        return $this;
    }

Here we check if this filter is applied to $request object, validate it (this should be an integer from 0 to 100, indicating the percentage of the discount). Next, we apply it to the collection and add it to the list of filters, which will be further used to render the block:

   Now Shopping by
    [x] Discount Exists: 20% - 30%
    Clear All

The second method in this class is responsible for calculating the results.

This method is executed almost at the end of all operations, after we have collected all the attributes, applied all filters, and counted the quantities of products for each attribute. The classical Magento uses only the values obtained earlier for this method. In our example, we’ll do it in a different way. Multiple private methods in Magento make it hard to create a method which would look structurally good. Moreover, since this method is responsible for calculating the results, then why don’t we calculate it right here.

So in this method we take the collection of products, get product IDs (using method getAllIds which doesn’t load the collection!). We create new select() from the index table and assign filter due to product_id. By simple steps we form an array which magento expects:

example: [
    [
        'label' => 'Has Discount',
        'value' => 3,
        'count' => 25
    ], ...

The code sample:

    protected function _getItemsData()
    {
        $tableName = $this->resource->getTableName(sprintf('discount_flat_%s', $this->getStoreId()));
        if (!$this->resource->getConnection()->isTableExists($tableName) || $this->isDiscountFilterApplied()) {
            return [];
        }
        /** @var \Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection $productCollection */
        $productCollection = $this->getLayer()->getProductCollection();
        $select = $this->resource->getConnection()->select()
            ->from(
                ['df' => $tableName],
                ['entity_id', sprintf('group_%s', $this->customerSession->getCustomerGroupId())]
            )
            ->where('entity_id IN (?)', $productCollection->getAllIds());
        /**
        * Here we have an array [product_id => discount_amount_value] for our customer group and our website
        */
        $pairsProductIdDiscountAmount = $this->resource->getConnection()->fetchPairs($select);
    /** Here we process this array to have result, described above. */
        return $result;
    }

So we have a class, now we need to assign our attribute to it.

There isn’t a predefined method for this (as we can understand it from the class \Magento\Catalog\Model\Layer\FilterList)

Let’s go back and extend it.

/**
     * Get Attribute Filter Class Name
     *
     * @param \Magento\Catalog\Model\ResourceModel\Eav\Attribute $attribute
     * @return string
     */
    protected function getAttributeFilterClass(\Magento\Catalog\Model\ResourceModel\Eav\Attribute $attribute)
    {
        $filterClassName = $this->filterTypes[self::ATTRIBUTE_FILTER];
        if ($attribute->getAttributeCode() == 'price') {
            $filterClassName = $this->filterTypes[self::PRICE_FILTER];
        } elseif ($attribute->getBackendType() == 'decimal') {
            $filterClassName = $this->filterTypes[self::DECIMAL_FILTER];
        }
        return $filterClassName;
    }

So that it could apply another type for our attribute. The one that we created earlier inheriting from AbstractFilter. Something like this:

} elseif ($attribute->getAttributeCode() == 'group_discount') {
            $filterClassName = '\Vendor\Module\Model\Layer\Filter\Discount';
        }

Now, when our filter already exists, let’s make filtration working.

I redefined the class by overlapping the config. I copied the existing catalog and indicated the type of my class.

Let’s go deeper into magento core.

We start from here: Magento\LayeredNavigation\Block\Navigation::_prepareLayout

Here in sequence we apply filters or, in other words, for each method we call method apply described earlier. By calling already mentioned method addFieldToFilter we fill the container with filters which we’re going to use further.

\Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection::addFieldToFilter
$this->searchCriteriaBuilder->addFilter($this->filterBuilder->create());

The filters are ready. Moving on.

Collection.php:727, Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\Interceptor->load()
AbstractCollection.php:906, Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\Interceptor->load()
Collection.php:361, Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\Interceptor->_renderFilters()
AbstractDb.php:338, Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\Interceptor->_renderFilters()
Collection.php:304, Magento\CatalogSearch\Model\ResourceModel\Fulltext\Collection\Interceptor->_renderFiltersBefore()
Search.php:58, Magento\Search\Model\Search->search()

When we execute render collection, as it’s seen from the queue, first we get to renderFilters, then renderFiltersBefore and eventually we get into the new class Search, method search (). That’s when the whole fun begins.

foreach ($searchCriteria->getFilterGroups() as $filterGroup) {
            foreach ($filterGroup->getFilters() as $filter) {
                $this->addFieldToFilter($filter->getField(), $filter->getValue());
            }
        }

Here we handle the added filters added earlier with the help of addFieldToFilter and add them all to object request in a specific structure. Example:

  requestBuilder {
          _data => [
            ...,
            placeholder => ['$category_ids$' => '6', '$activity$' => '9', '$price_dynamic_algorithm$' => 'auto']
    }

Further we apply sorting, pagination, etc. Done. We create request:

$request = $this->requestBuilder->create();

At this point, we get an array from the config that contains the filters created earlier from the attributes. When I started performing this customization I hadn’t thought about making an imitation of filter with the help of real attribute and found that the following structure is added with the help of the attribute:

+        $data['queries']['catalog_view_container']['queryReference'][] = ['clause' => 'must', 'ref' => 'group_discount'];
+
+        $data['queries']['group_discount']['name'] = 'group_discount';
+        $data['queries']['group_discount']['filterReference'][0]['clause'] = 'must';
+        $data['queries']['group_discount']['filterReference'][0]['ref'] = 'group_discount_filter';
+        $data['queries']['group_discount']['type'] = 'filteredQuery';
+
+        $data['filters']['group_discount_filter']['name'] = 'group_discount_filter';
+        $data['filters']['group_discount_filter']['field'] = 'group_discount';
+        $data['filters']['group_discount_filter']['from'] = '$group_discount.from$';
+        $data['filters']['group_discount_filter']['to'] = '$group_discount.to$';
+        $data['filters']['group_discount_filter']['type'] = 'rangeFilter';
+
+        $data['aggregations']['group_discount_bucket']['name'] = 'group_discount_bucket';
+        $data['aggregations']['group_discount_bucket']['type'] = 'dynamicBucket';
+        $data['aggregations']['group_discount_bucket']['field'] = 'group_discount';
+        $data['aggregations']['group_discount_bucket']['method'] = '$price_dynamic_algorithm$';
+        $data['aggregations']['group_discount_bucket']['metric'][0]['type'] = 'count';

Once the array is formed, we start to apply filters. It passes in two stages:

$data = $this->binder->bind($data, $this->data);
        $data = $this->cleaner->clean($data);

We set bind flag near all used filters, and remove the extra ones with the second operation.

Next the following interesting point comes: the query is being built.

Search.php:72, Magento\Search\Model\Search->search()
SearchEngine.php:42, Magento\Search\Model\SearchEngine->search()
Adapter.php:77, Magento\Framework\Search\Adapter\Mysql\Adapter->query()
Mapper.php:136, Magento\Framework\Search\Adapter\Mysql\Mapper->buildQuery()

Here we apply all our filters to the collection (the filtered array described above) that we selected in Layered Navigation. And this is the place where we need to create a custom filter. If you remember, we created an attribute with the decimal type and magento will search for it in the corresponding table (which is not something that we need). Since by condition we need to pull it from our index table - we need to change native workflow of this filter.

The general principle by which we add filter is the following. There is a global select, we create a nested select for which we assign our condition. From the nested select we extract only product IDs which are added to where of our initial select.

The only class for which we managed to assign plugin was:

<type name="Magento\Framework\Search\Adapter\Mysql\Filter\Builder">
        <plugin name="changed_build_query_for_our_attribute" type="Vendor\Module\Plugin\BuildDiscoutSubquery" />
    </type>

For this method we borrow the functionality from here: \Magento\CatalogSearch\Model\Adapter\Mysql\Filter\Preprocessor::processRangeNumeric with a slight change:

/**
     * Similar functionality implemented here:
     * \Magento\CatalogSearch\Model\Adapter\Mysql\Filter\Preprocessor::processRangeNumeric
     *
     * We are creating subSelect which is retrieving list of product Ids and we are adding this list to
     * existing query to get list of intersected products.
     *
     * @param \Magento\Framework\Search\Adapter\Mysql\Filter\Builder $builder
     * @param \Closure $proceed
     * @param \Magento\Framework\Search\Request\FilterInterface $filter
     * @param $condition
     *
     * @return mixed|string
     */
    public function aroundBuild(
        \Magento\Framework\Search\Adapter\Mysql\Filter\Builder $builder,
        \Closure $proceed,
        \Magento\Framework\Search\Request\FilterInterface $filter,
        $condition
    ) {
        if ($filter->getField() == 'group_discount') {
            $currentStoreId = $this->scopeResolver->getScope()->getId();
            $discountFlatTable = $this->connection->getTableName(sprintf('discount_flat_%s', $currentStoreId));
            $customerGroupId = $this->session->getCustomerGroupId();
            $columnGroupName = sprintf('group_%s', $customerGroupId);
            $select = $this->connection->select()
                ->from(['df' => $discountFlatTable], ['entity_id'])
                ->where(sprintf('df.%s >= %s', $columnGroupName, $filter->getFrom()))
                ->where(sprintf('df.%s < %s', $columnGroupName, $filter->getTo()));
            $resultQuery = 'search_index.entity_id IN (
                select entity_id from ' . $this->conditionManager->wrapBrackets($select) . ' as filter
            )';
            return $resultQuery;
        }
        return $proceed($filter, $condition);
    }

Thus, specifically for group_discount filter we’ll apply custom filtering for section WHERE.

You can check the query you get by putting breakpoint here Magento\Framework\Search\Adapter\Mysql\Mapper row: 149:

SELECT `main_select`.`entity_id`, MAX(score) AS `relevance` FROM (SELECT `search_index`.`entity_id`, (((0) + (0) + (0)) * 1) AS `score` FROM `catalogsearch_fulltext_scope1` AS `search_index`
LEFT JOIN `catalog_eav_attribute` AS `cea` ON search_index.attribute_id = cea.attribute_id
LEFT JOIN `catalog_category_product_index` AS `category_ids_index` ON search_index.entity_id = category_ids_index.product_id
LEFT JOIN `catalog_product_index_eav` AS `activity_filter` ON search_index.entity_id = activity_filter.entity_id AND activity_filter.attribute_id = 135 AND activity_filter.store_id = 1
LEFT JOIN `cataloginventory_stock_status` AS `stock_index` ON search_index.entity_id = stock_index.product_id AND stock_index.website_id = 0 WHERE (stock_index.stock_status = 1) AND (category_ids_index.category_id = 6) AND (activity_filter.value = '9') AND (search_index.entity_id IN (
                select entity_id from (SELECT `df`.`entity_id` FROM `discount_flat_1` AS `df` WHERE (df.group_1 >= 10) AND (df.group_1 < 19.9999)) as filter
            ))) AS `main_select` GROUP BY `entity_id` ORDER BY `relevance` DESC

Here we see the nested subselect.

During development, I came across the fact that a temporary table is added to the query. For debugging, we can temporarily change its creation in the core by replacing CREATE TEMPORARY TABLE with CREATE TABLE in this file:
row 2022: Magento\Framework\DB\Adapter\Pdo\Mysql::createTemporaryTable.

Summing up.

In fact, all customization comes down to the creation of 1 attribute and 4 methods.

Thank you for reading our articles.


Comments

© Extait, 2019