Magento2 – Use Preconfigured Builders for SearchCriteria

Architecturebest practicemagento2search-criteria

Warning, this is a long winded question about module architecture. I'm not mad if it gets closed as opinion based, but I hope someone has a better idea than me.

In Magento 2 we have builders like the SearchCriteriaBuilder which we can inject into our classes similar to factories to let us create objects, in this case SearchCriteria (filters, sort order, page size).

What I like about the builder pattern in general (not Magento specific), is that you can create named constructors for predefined configurations (i.e. parameters for the object to be created) but still can change these configurations before the object is created.

Fictional Example:

$shop = ShopBuilder::demoShop()->withSampleData()->build();

demoShop() returns a preconfigured builder, withSampleData() changes it and build() creates the object.

Magento 2

In Magento 2, named constructors are not really a thing, because they cannot make use of the object manager. Virtual types might be an alternative but they are entirely defined in di.xml, no PHP code. This is a bit limiting and obscure IMHO.

What I want:

I have some search criteria combinations that I need in different places with slight variations and don't want to configure them from scratch all the time, given the verbosity of their creation.

What I have:

I currently created separate classes that delegate to the actual SearchCriteriaBuilder.

This is for example a builder for a "all varchar attributes, sorted by label" search criteria with an optional blacklist:

namespace IntegerNet\Solr\Model\SearchCriteria;

use Magento\Eav\Model\Entity\Collection\AbstractCollection;
use Magento\Framework\Api\Filter;
use Magento\Framework\Api\Search\SearchCriteriaBuilder;
use Magento\Framework\Api\SimpleBuilderInterface;

class VarcharAttributes implements SimpleBuilderInterface
{

    protected $searchCriteriaBuilder;

    public function __construct(SearchCriteriaBuilder $searchCriteriaBuilder)
    {
        $this->searchCriteriaBuilder = $searchCriteriaBuilder;
        $searchCriteriaBuilder->addSortOrder('frontend_label', AbstractCollection::SORT_ORDER_ASC);
        $searchCriteriaBuilder->addFilter(new Filter([
            Filter::KEY_FIELD => 'backend_type',
            Filter::KEY_CONDITION_TYPE => 'in',
            Filter::KEY_VALUE => ['static', 'varchar']
        ]));
        $searchCriteriaBuilder->addFilter(new Filter([
            Filter::KEY_FIELD => 'frontend_input',
            Filter::KEY_VALUE => 'text'
        ]));
    }
    /**
     * Exclude attributes by code
     *
     * @param array $attributeCodes
     * @return $this
     */
    public function except(array $attributeCodes)
    {
        $searchCriteriaBuilder = $this->searchCriteriaBuilder;
        $searchCriteriaBuilder->addFilter(new Filter([
            Filter::KEY_FIELD => 'attribute_code',
            Filter::KEY_CONDITION_TYPE => 'nin',
            Filter::KEY_VALUE => $attributeCodes
        ]));
        return $this;
    }
    public function create()
    {
        return $this->searchCriteriaBuilder->create();
    }
    public function getData()
    {
        return $this->searchCriteriaBuilder->getData();
    }
}

In the constructor, the actual builder gets preconfigured and in except() this configuration can be extended. Now I can inject this class when I need this particular search criteria and use them like this:

$this->attributeRepository->getList(
    $this->varcharAttributes->except(['name'])->create()
);

But I'm not happy with it

Why?

The builder is not immutable. As soon as I make changes to its configuration (the except() call in the example), the default configuration is lost.

If I have these subsequent calls in one class:

$this->varcharAttributes->create()

$this->varcharAttributes->except(['name'])->create()

$this->varcharAttributes->create()

The second and third call create a SearchCriteria with the additional "name" filter.

Usually I would solve this by returning a new instance from the modifiers like except() but I did not find a good way to do that yet. clone $this does not help because the SearchCriteriaBuilder of Magento does not allow deep cloning and references a FilterGroupBuilder and a FilterBuilder.

Of course I could create a factory for each of my custom builders but that feels a bit too much, even if it's one factory for several related builders.

So my question is: Is there a clean way to use preconfigured and preferably immutable builders? Or do I really need to create a factory that creates custom builders that use the core builder?

Best Answer

I love the ideas you present. Immutability doesn't go well with builders, having state is part of their job. So how about this, which is very close to what you suggest anyway, if I understand you correctly:

  1. Inject a generated SearchCriteriaBuilderFactory instead of the SearchCriteriaBuilder.
  2. Store the except() method arguments in an $except array property.
  3. In the create() method, get a new SearchCriteriaBuilder instance from the factory, configure it with the default values, apply the exceptions, and then reset $except before you return the SearchCriteria instance.

This way each call to create() will use a fresh builder instance and return a fresh SearchCriteria instance just as you want.
And you don't have to create any factories manually. The one factory you need can be generated and that can even be shared between your custom builders.

<?php

namespace IntegerNet\Solr\Model\SearchCriteria;

use Magento\Eav\Model\Entity\Collection\AbstractCollection;
use Magento\Framework\Api\Filter;
use Magento\Framework\Api\Search\SearchCriteriaBuilderFactory;
use Magento\Framework\Api\SimpleBuilderInterface;

class VarcharAttributes implements SimpleBuilderInterface
{
    private $searchCriteriaBuilderFactory;
    private $except = [];

    public function __construct(SearchCriteriaBuilderFactory $searchCriteriaBuilderFactory)
    {
        $this->searchCriteriaBuilderFactory = $searchCriteriaBuilderFactory;
    }

    private function createVarcharSearchCriteriaBuilder()
    {
        $searchCriteriaBuilder = $this->searchCriteriaBuilderFactory->create();
        $this->applyDefaultFilters($searchCriteriaBuilder);
        return $searchCriteriaBuilder;
    }

    private function applyDefaultFilters(SearchCriteriaBuilder $searchCriteriaBuilder)
    {
        $searchCriteriaBuilder->addSortOrder('frontend_label', AbstractCollection::SORT_ORDER_ASC);
        $searchCriteriaBuilder->addFilter(new Filter([
            Filter::KEY_FIELD => 'backend_type',
            Filter::KEY_CONDITION_TYPE => 'in',
            Filter::KEY_VALUE => ['static', 'varchar']
        ]));
        $searchCriteriaBuilder->addFilter(new Filter([
            Filter::KEY_FIELD => 'frontend_input',
            Filter::KEY_VALUE => 'text'
        ]));
    }

    public function except(array $attributeCodes)
    {
        $self = clone $this;
        $self->except = array_merge($this->except, $attributeCodes);
        return $self;
    }

    public function create()
    {
        $searchCriteriaBuilder = $this->createVarcharSearchCriteriaBuilder();
        $this->applyExceptions($searchCriteriaBuilder);
        return $searchCriteriaBuilder->create();
    }

    private function applyExceptions(SearchCriteriaBuilder $searchCriteriaBuilder)
    {
        $searchCriteriaBuilder->addFilter(new Filter([
            Filter::KEY_FIELD => 'attribute_code',
            Filter::KEY_CONDITION_TYPE => 'nin',
            Filter::KEY_VALUE => $this->except
        ]));
    }