-1

Our aim is to pull certain promoted products in the product listing to front.

Important: The promoted products differ by category / filter, so it would not work to just insert a custom field or use the "promoted products" flag which is already built in. We already have access to the the product IDs to pull to front, we just need to sort the list accordingly.

We subscribed to ProductListingCriteriaEvent::class and tried something - based on https://stackoverflow.com/a/6810827/288568 - like this:

$criteria = $event->getCriteria()
$sortings = $criteria->getSorting();
$criteria->resetSorting();
 
$criteria->addSorting(new FieldSorting('FIELD(id, 0x123456...)', FieldSorting::DESCENDING));

foreach($sortings as $sorting) {
    $criteria->addSorting($sorting);
}

Where 0x123456... would be the UUID of the product to to pull to front.

This of course does not work, because Shopware expects a field.

Is it possible to create something like a "virtual" field for this reason or are there other ways to insert such a raw SQL part?

Alex
  • 32,506
  • 16
  • 106
  • 171

2 Answers2

2

Adding a New Custom Sorting facility by decorating the QueryBuilder

We can implement a new Sorting class which takes specific ids to pull to front and then decorate the CriteriaQueryBuilder to add this new sorting type.

Implementation details (tested on Shopware 6.4.6.0)

First we define a class to hold the information for the new sorting method:

CustomSorting.php

<?php declare(strict_types=1);

namespace ExampleProductListing\Framework\DataAbstractionLayer\Search\Sorting;

use Shopware\Core\Framework\DataAbstractionLayer\Search\Sorting\FieldSorting;


class CustomSorting extends FieldSorting
{
    private array $ids = [];


    public function addId(string $id)
    {
        $this->ids[] = $id;
    }

    public function getIds()
    {
        return $this->ids;
    }
}

Next, we define a decorator for the CriteriaQueryBuilder:

services.xml

<service id="ExampleProductListing\Framework\DataAbstractionLayer\Dbal\CriteriaQueryBuilderDecorator"
             decorates="Shopware\Core\Framework\DataAbstractionLayer\Dbal\CriteriaQueryBuilder">
    <argument type="service" id="ExampleProductListing\Framework\DataAbstractionLayer\Dbal\CriteriaQueryBuilderDecorator.inner"/>
    <argument type="service" id="Shopware\Core\Framework\DataAbstractionLayer\Search\Parser\SqlQueryParser"/>
    <argument type="service" id="Shopware\Core\Framework\DataAbstractionLayer\Dbal\EntityDefinitionQueryHelper"/>
    <argument type="service" id="Shopware\Core\Framework\DataAbstractionLayer\Search\Term\SearchTermInterpreter"/>
    <argument type="service" id="Shopware\Core\Framework\DataAbstractionLayer\Search\Term\EntityScoreQueryBuilder"/>
    <argument type="service" id="Shopware\Core\Framework\DataAbstractionLayer\Dbal\JoinGroupBuilder"/>
    <argument type="service"
              id="Shopware\Core\Framework\DataAbstractionLayer\Dbal\FieldResolver\CriteriaPartResolver"/>

</service>

Next, we implement the decorator, which holds the new logic for generating the SQL with the FIELD() method.

CriteriaQueryBuilderDecorator.php

<?php declare(strict_types=1);

namespace ExampleProductListing\Framework\DataAbstractionLayer\Dbal;

use ExampleProductListing\Framework\DataAbstractionLayer\Search\Sorting\CustomSorting;
use Shopware\Core\Framework\Context;
use Shopware\Core\Framework\DataAbstractionLayer\Dbal\CriteriaQueryBuilder;
use Shopware\Core\Framework\DataAbstractionLayer\Dbal\EntityDefinitionQueryHelper;
use Shopware\Core\Framework\DataAbstractionLayer\Dbal\FieldResolver\CriteriaPartResolver;
use Shopware\Core\Framework\DataAbstractionLayer\Dbal\JoinGroupBuilder;
use Shopware\Core\Framework\DataAbstractionLayer\Dbal\QueryBuilder;
use Shopware\Core\Framework\DataAbstractionLayer\EntityDefinition;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\Filter;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Parser\SqlQueryParser;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Term\EntityScoreQueryBuilder;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Term\SearchTermInterpreter;

class CriteriaQueryBuilderDecorator extends CriteriaQueryBuilder
{
    private $decoratedService;


    /***
     * @var EntityDefinitionQueryHelper
     */
    private $helper;

    public function __construct(
        CriteriaQueryBuilder        $decoratedService,
        SqlQueryParser              $parser,
        EntityDefinitionQueryHelper $helper,
        SearchTermInterpreter       $interpreter,
        EntityScoreQueryBuilder     $scoreBuilder,
        JoinGroupBuilder            $joinGrouper,
        CriteriaPartResolver        $criteriaPartResolver
    )
    {
        $this->decoratedService = $decoratedService;
        $this->helper = $helper;

        parent::__construct($parser, $helper,$interpreter, $scoreBuilder, $joinGrouper, $criteriaPartResolver);
    }

    public function getDecorated(): CriteriaQueryBuilder
    {
        return $this->decoratedService;
    }

    public function addSortings(EntityDefinition $definition, Criteria $criteria, array $sortings, QueryBuilder $query, Context $context): void
    {
        foreach ($sortings as $sorting) {
            if ($sorting instanceof CustomSorting) {

                $accessor = $this->helper->getFieldAccessor($sorting->getField(), $definition, $definition->getEntityName(), $context);

                $ids = implode(',', array_reverse($sorting->getIds()));
                if (empty($ids)) {
                    continue;
                }

                $query->addOrderBy('FIELD(' . $accessor . ',' . $ids . ')', 'DESC');
            } else {
                $this->decoratedService->addSortings($definition, $criteria, [$sorting], $query, $context);
            }
        }
    }

    public function build(QueryBuilder $query, EntityDefinition $definition, Criteria $criteria, Context $context, array $paths = []): QueryBuilder
    {
        return parent::build($query, $definition, $criteria, $context, $paths);
    }

    public function addFilter(EntityDefinition $definition, ?Filter $filter, QueryBuilder $query, Context $context): void
    {
        parent::addFilter($definition, $filter, $query, $context);
    }

}

How to use the new sorting method

Finally, when building the criteria (for example in ProductListingCriteriaEvent) we can pull specific products to front by specifying there IDs. (hard coded here, in real world they come from a different source, which depends on the chosen filters)

    $customSorting = new CustomSorting('product.id');
    $customSorting->addId('0x76f9a07e153645d7bd8ad62abd131234');
    $customSorting->addId('0x76a890cb23ea433a97006e71cdb75678');
    $event->getCriteria()
        ->addSorting($customSorting);

Compatibility

This works only for the SQL engine. If ElasticSearch should also be supported, this probably would work by decorating the ElasticSearch Query Buidler as well.

Alex
  • 32,506
  • 16
  • 106
  • 171
  • 1
    If you want to use your custom sorting also over the API, make sure to decorate the `RequestCriteriaBuilder` too, that class handles converting the request payload to an `Criteria` object. – j_elfering Nov 29 '21 at 06:58
0

Raw SQL is by design not supported, as the query with that criteria can also be parsed and executed by ElasticSearch if you use the ElasticSearch integration.

Instead of handling it all within the read you could use a simple custom field that you sort for. You could add a myCustomSorting custom field to the products and set the value of that field to 1 for all products that you want to show up first. Then you extend the criteria to first sort by that field.

j_elfering
  • 2,707
  • 6
  • 15
  • Yeah, doesn't need to be Raw SQL - just a way to sort the way we need :-) We do not need to support elasticsearch in the moment. A custom field wouldn't do it, because the promoted products differ per category, that means product A might need to show on top in category X but not in category Y (even it's still contained in category Y) – Alex Nov 19 '21 at 07:58
  • you could use one customField per category `categoryXSorting = 99999` `categoryYSorting = -1000`, but that definetly won't scale for much more than 10 categories – j_elfering Nov 19 '21 at 14:12
  • It's actually not the category but a filter setting plus category we depend on ...yes that doesn't scale – Alex Nov 19 '21 at 14:56
  • 1
    Maybe this can be better achieved in ElasticSearch? Never tried this in Shopware, but we had a similar requirement in another ecommerce framework (based on Java, using SQL but also ES) and added an optional boost factor / weight to the items to be promoted. – Ingo Steinke Nov 20 '21 at 11:03
  • I found a solution, see my answer. Thanks for your help :-) – Alex Nov 25 '21 at 18:38