@benjamincremer

Product Search

Benjamin Cremer
Core Developer shopware AG

Benjamin Cremer

@benjamincremer

Questions?

Please ask any time.

shopware AG

AntiDemo

Problems

  • Duplicate Code
  • Tight Coupling to Persistence layer
  • Tight Coupling to framework
  • Primitive extensibility
  • Big Queries that select too much
  • Performance / Scale issues
  • Undocumented Arrays with minor differences

        BaseProduct < ListProduct < Product
    

BaseProduct: Product Identification

ListProduct: Basic Product Data (Catogory Listing, Related Products)

Product: Full Product data, contains ListProducts (Product Detail Page)

Separation of concerns

  • Search Definition
  • Find product numbers
  • Fetch Productdata by product number
  • Bridge to framework

Search Definition

  • Description of what to search for
  • You will find no WHERE or INNER JOIN here
  • Lets call that "Criteria"

Criteria


$criteria = new \Shopware\Bundle\SearchBundle\Criteria();
    

$criteria->addCondition(
    new HasFreeShippingCondition()
);
    

$criteria->addCondition(
    new SearchTermCondition('tee')
);
    

$criteria->addCondition(
    new PriceRangeCondition(5, 50)
);
    

$criteria->addSorting(
    new PriceSorting(SortingInterface::SORT_ASC)
);
    

$criteria->limit(5);
$criteria->offset(0);
    

Condition


namespace Shopware\Bundle\SearchBundle\Condition;

class PriceRangeCondition implements ConditionInterface
{
    /**
     * @param float $minPrice
     * @param float $maxPrice
     */
    public function __construct($minPrice, $maxPrice)
    {
        Assertion::float($minPrice); // see github.com/beberlei/assert
        Assertion::float($maxPrice);

        $this->minPrice = $minPrice;
        $this->maxPrice = $maxPrice;
    }

    public function getName() {};
    public function getMinPrice() {};
    public function getMaxPrice() {};

}
    

Condition

  • Value Object
  • Immutable
  • only scalars
  • Domain Specific
  • Ensures basic consistency
  • We can easily persist that

  • Search Definition ✔
  • Find Product Numbers?

Context

  • Global shop state
  • User / Runtime dependent information


/** @var ContextServiceInterface $contextService */
$contextService = $container->get('shopware_storefront.context_service_core');

$context = $contextService->getProductContext();
    

$context->getShop()->getId(); // 1
$context->getFallbackCustomerGroup()->getKey(); // EK
    

Search Interface


namespace Shopware\Bundle\SearchBundle;

interface ProductNumberSearchInterface
{
    /**
     * @param Criteria $criteria
     * @param ShopContextInterface $context
     * @return ProductNumberSearchResult
     */
    public function search(Criteria $criteria, ShopContextInterface $context);
}
    

Search


$criteria = Criteria();
$criteria->addCondition(new SearchTermCondition('tee'));
$criteria->limit(5);
[..]
$context = [..]
    

/** @var ProductNumberSearchInterface $search */
$search = $this->get('shopware_searchdbal.product_number_search');

/** @var ProductNumberSearchResult $result */
$result = $search->search($criteria, $context);
    

echo $result->getTotalCount();

/** @var BaseProduct $product */
foreach ($result->getProducts() as $product) {
    echo $product->getNumber(); // $product->getId(), $product->getVariantId();
}
    
  • Search Definition ✔
  • Find Product Numbers ✔
  • Fetch Product Data ?

Fetch Product Data


interface ListProductServiceInterface
{
    /**
     * @return Struct\ListProduct
     */
    public function get($number, ProductContextInterface $context);

    /**
     * @return Struct\ListProduct[] Indexed by the products order number
     */
    public function getList(array $numbers, ProductContextInterface $context);
}
    

interface ProductServiceInterface
{
    /**
     * @return Struct\Product
     */
    public function get($number, ProductContextInterface $context);

    /**
     * @return Struct\Product[] Indexed by the products order number
     */
    public function getList($numbers, ProductContextInterface $context);
}
    

/** @var ListProductService $productListService */
$productListService = $this->get('shopware_storefront.list_product_service');

/** @var ListProduct $product */
$product = $productListService->get('sw-555', $context);

/** @var ListProduct[] $products */
$products = $productListService->getList(['sw-555', 'sw-111'], $context);
    
  • Search Definition ✔
  • Find Product Numbers ✔
  • Fetch Product Data ✔
  • Combine both?

/** @var ProductNumberSearchResult $searchResult */
$searchResult = $this->productNumberSearch->search($condition, $context);

/** @var ListProductService $productListService */
$productListService = $this->get('shopware_storefront.list_product_service');
    

$listProducts = [];
foreach ($result->getProducts() as $product) {
    $listProducts = $productListService->get($product->getNumber(), $context);
}
    

// Better:
$listProducts = $productListService->get(
    array_keys($result->getProducts()),
    $context
);
// Produces one single IN query (default DBAL implementation)
    

namespace Shopware\Bundle\SearchBundle;

use Shopware\Bundle\StoreFrontBundle\Struct;

interface ProductSearchInterface
{
    /**
     * @return ProductSearchResult
     */
    public function search(Criteria $criteria, ProductContextInterface $context);
}
    

class ProductSearch implements ProductSearchInterface
{
    public function __construct(
        ProductNumberSearchInterface $search         // ElasticSearch
        ListProductServiceInterface $productService, // Doctrine DBAL
    );

    /**
     * @return ProductSearchResult
     */
    public function search(Criteria $criteria, ProductContextInterface $context);
}
    

$condition = [..];
$context   = [..];

/** @var ProductSearchInterface $search */
$search = $this->get('shopware_search.product_search');

$result = $search->search($criteria, $context);

echo "Totalcount: ". $result->getTotalCount(). "\n";

/** @var ListProduct $product */
foreach ($result->getProducts() as $product) {
    echo $product->getName();
};
    
  • Search Definition ✔
  • Find Product Numbers ✔
  • Fetch Product Data ✔
  • Combine both ✔

Framework Bridge


namespace ShopwarePlugins\AcmeExamplePlugin;

class CriteriaRequestHandler implements CriteriaRequestHandlerInterface
{
    public function handleRequest(
        Request $request,
        Criteria $criteria,
        ShopContextInterface $context
    ) {
        if (!$request->hasParam('foos')) {
            return;
        }

        $fooIds = explode(
            '|',
            $request->getParam('foos')
        );

        if (!empty($fooIds)) {
            $criteria->addCondition(new FooCondition($fooIds));
        }
    }
}
    

Implementation


interface ConditionHandlerInterface
{
    /**
     * @param ConditionInterface $condition
     * @return bool
     */
    public function supportsCondition(ConditionInterface $condition);

    /**
     * @param ConditionInterface $condition
     * @param QueryBuilder $query
     * @param ShopContextInterface $context
     */
    public function generateCondition(
        ConditionInterface $condition,
        QueryBuilder $query,
        ShopContextInterface $context
    );
}
    

class ManufacturerConditionHandler implements ConditionHandlerInterface
{
    public function supportsCondition(ConditionInterface $condition)
    {
        return ($condition instanceof ManufacturerCondition);
    }

    public function generateCondition(
        ConditionInterface $condition,
        QueryBuilder $query,
        ShopContextInterface $context
    ) {
        /* @var $condition ManufacturerCondition */

        $query->innerJoin(
            'product',
            's_articles_manufacturer',
            'manufacturer',
            'manufacturer.id = product.manufacturerID
             AND product.manufacturerID IN (:manufacturer)'
        );

        $query->setParameter(
            ':manufacturer',
            $condition->getManufacturerIds(),
            Connection::PARAM_INT_ARRAY
        );
    }
}
    

class ProductNameSortingHandler implements SortingHandlerInterface
{
    public function supportsSorting(SortingInterface $sorting)
    {
        return ($sorting instanceof ProductNameSorting);
    }

    public function generateSorting(
        SortingInterface $sorting,
        QueryBuilder $query,
        ShopContextInterface $context
    ) {
        $query->addOrderBy('product.name', $sorting->getDirection())
              ->addOrderBy('product.id', $sorting->getDirection());
    }
}

    

Elasticsearch

  • Non-Comment Lines of Code (NCLOC) 2382
  • Basic integration in Shopware 5.1

Extensibility

Just add own Conditions & Handlers

Extensibility via Decoration


class CachedSearch implements SearchInterface
{
    private $innerSearch;
    private $cache;

    public function __construct(SearchInterface $innerSearch, Cache $cache)
    {
        $this->innerSearch = $innerSearch;
        $this->cache = $cache;
    }

    public function search(Criteria $criteria, Struct\ShopContextInterface $context)
    {
        $key = stringify($criteria, $context);
        if ($result = $this->cache->get($key)) {
            return $result;
        }

        $result = $this->innerSearch->search($criteria, $context);
        $this->cache->put($key, $result);

        return $result;
    }
}
    

Questions?

Thank you!