first commit
This commit is contained in:
375
bundles/KupShop/CatalogElasticBundle/Util/ElasticQueryClient.php
Normal file
375
bundles/KupShop/CatalogElasticBundle/Util/ElasticQueryClient.php
Normal file
@@ -0,0 +1,375 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace KupShop\CatalogElasticBundle\Util;
|
||||
|
||||
use Elastica\Multi\Search as MultiSearch;
|
||||
use Elastica\Query\BoolQuery;
|
||||
use Elastica\Query\FunctionScore;
|
||||
use Elastica\Query\MultiMatch;
|
||||
use KupShop\CatalogBundle\ProductList\DynamicFilterAttributes;
|
||||
use KupShop\CatalogBundle\Search\FulltextElastic;
|
||||
use KupShop\CatalogBundle\Util\FilterUtil;
|
||||
use KupShop\CatalogElasticBundle\Elastica\Query;
|
||||
use KupShop\CatalogElasticBundle\Event\SectionAfterExecuteEvent;
|
||||
use KupShop\CatalogElasticBundle\Event\SectionBeforeCompileEvent;
|
||||
use KupShop\CatalogElasticBundle\Event\SectionBeforeExecuteEvent;
|
||||
use KupShop\CatalogElasticBundle\Filters\Search;
|
||||
use KupShop\CatalogElasticBundle\PreparedSectionQuery;
|
||||
use KupShop\CatalogElasticBundle\SectionRequest;
|
||||
use KupShop\RestrictionsBundle\Utils\Restrictions;
|
||||
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||
|
||||
class ElasticQueryClient
|
||||
{
|
||||
/**
|
||||
* Default limit for the number of results that can be "paginated" over (with from + size fields).
|
||||
*
|
||||
* @see https://www.elastic.co/guide/en/elasticsearch/reference/current/index-modules.html#index-max-result-window
|
||||
*/
|
||||
public const ELASTIC_RESULTS_LIMIT = 10_000;
|
||||
|
||||
/**
|
||||
* @var int Default maximum number of result returned from a search. Used when \Pager is not supplied.
|
||||
*/
|
||||
public static int $maxResults = 100;
|
||||
|
||||
/**
|
||||
* @var string The name of the query which contains productIds
|
||||
*/
|
||||
public static string $mainQueryName = 'main';
|
||||
|
||||
/**
|
||||
* @var string The name of an optional query with the aggregations of filters in a non-filtered category (for baseFilterParams)
|
||||
*/
|
||||
public static string $baseQueryName = 'base';
|
||||
|
||||
public function __construct(
|
||||
private readonly ElasticSpecs $elasticSpecs,
|
||||
private readonly ElasticaFactory $elasticaFactory,
|
||||
private readonly FulltextElastic $fulltextElastic,
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly ElasticFilterValues $filterValuesCompiler,
|
||||
private readonly ?Restrictions $restrictions,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Search an index with a string query.
|
||||
*
|
||||
* @param string $searchTerm query string
|
||||
* @param string $index as defined by {@see FulltextElastic}::INDEX_* constants
|
||||
* @param array<string>|null $fields fields to return
|
||||
*/
|
||||
public function createGenericSearch(
|
||||
string $searchTerm,
|
||||
string $index,
|
||||
?array $fields = null,
|
||||
?\Pager $pager = null,
|
||||
): \Elastica\Search {
|
||||
$query = new Query();
|
||||
$query->setSize($pager?->getOnPage() ?? self::$maxResults);
|
||||
if (isset($pager?->number)) {
|
||||
$query->setFrom($pager->from());
|
||||
}
|
||||
|
||||
if (is_array($fields)) {
|
||||
$query->setSource(false);
|
||||
$query->setFields($fields);
|
||||
}
|
||||
|
||||
if ($index === FulltextElastic::INDEX_PRODUCTS) {
|
||||
$filterParams = new \FilterParams();
|
||||
$filterParams->setSearch($searchTerm);
|
||||
|
||||
// Run the search filter through ElasticFilters - unified place for searching products.
|
||||
$filters = $this->elasticSpecs->getFilters($filterParams);
|
||||
if (!$filters) {
|
||||
throw new \RuntimeException(sprintf(
|
||||
'Failed to generate a search query (index: %s) for the query "%s".',
|
||||
$index,
|
||||
$searchTerm,
|
||||
));
|
||||
}
|
||||
|
||||
$productSearchQuery = new BoolQuery();
|
||||
foreach ($filters as $filter) {
|
||||
$productSearchQuery->addMust($filter);
|
||||
}
|
||||
|
||||
$this->elasticSpecs->applyRestrictionFilters($this->restrictions?->getRestrictionFilterParams(), $productSearchQuery);
|
||||
|
||||
$query->setQuery($productSearchQuery);
|
||||
} else {
|
||||
$score = new FunctionScore();
|
||||
$score->setQuery($searchQuery = new MultiMatch());
|
||||
$searchQuery->setQuery($searchTerm);
|
||||
$searchQuery->setType(MultiMatch::TYPE_CROSS_FIELDS);
|
||||
$searchQuery->setOperator(MultiMatch::OPERATOR_AND);
|
||||
|
||||
$searchFields = $this->fulltextElastic->formatQueryFields($this->fulltextElastic->getSearchFields($index));
|
||||
$searchQuery->setFields($searchFields);
|
||||
|
||||
$query->setQuery($score);
|
||||
}
|
||||
|
||||
$search = $this->elasticaFactory->createSearch($index);
|
||||
$search->setQuery($query);
|
||||
|
||||
return $search;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares an executable ES search (lazy - will be executed when some field is requested from the response).
|
||||
* Also registers event listeners.
|
||||
*
|
||||
* @param SectionRequest $request parameters needed to create the ES query
|
||||
* @param \ProductList $productList target product list,
|
||||
* which will be filtered by product ids when the ES search executes
|
||||
* @param \Filter $filter will be filled with filter values from ES aggregations
|
||||
* @param ?\Pager $pager the size will be set to the value of the number of products found by ES
|
||||
* @param DynamicFilterAttributes $filterAttributes a list of allowed filters in the section (as well as some data to be added to the $filter valuesCache)
|
||||
*/
|
||||
public function prepareSectionQuery(
|
||||
SectionRequest $request,
|
||||
\ProductList $productList,
|
||||
\Filter $filter,
|
||||
?\Pager $pager,
|
||||
DynamicFilterAttributes $filterAttributes,
|
||||
): PreparedSectionQuery {
|
||||
$preparedRequest = new PreparedSectionQuery(
|
||||
$request,
|
||||
$this->compileCategoryQuery(...),
|
||||
$this->filterValuesCompiler,
|
||||
$productList,
|
||||
$filter,
|
||||
$pager,
|
||||
$filterAttributes,
|
||||
);
|
||||
|
||||
$eventDispatcher = $this->dispatcher;
|
||||
$preparedRequest->onBeforeCompile(static function () use ($eventDispatcher) {
|
||||
$event = new SectionBeforeCompileEvent(...func_get_args());
|
||||
|
||||
$eventDispatcher->dispatch($event);
|
||||
});
|
||||
|
||||
$preparedRequest->onBeforeExecute(static function () use ($eventDispatcher) {
|
||||
$event = new SectionBeforeExecuteEvent(...func_get_args());
|
||||
|
||||
$eventDispatcher->dispatch($event);
|
||||
});
|
||||
|
||||
$preparedRequest->onAfterExecute(static function () use ($eventDispatcher) {
|
||||
$event = new SectionAfterExecuteEvent(...func_get_args());
|
||||
|
||||
$eventDispatcher->dispatch($event);
|
||||
});
|
||||
|
||||
return $preparedRequest;
|
||||
}
|
||||
|
||||
private function compileCategoryQuery(SectionRequest $request): MultiSearch
|
||||
{
|
||||
$multiSearch = $this->elasticaFactory->createMultiSearch();
|
||||
|
||||
if ($request->pager) {
|
||||
assert(
|
||||
$request->pager->to() < self::ELASTIC_RESULTS_LIMIT,
|
||||
'Elasticsearch indexy mají limit 10 000 výsledků. Stránkování nad 10 000 výsledků není možné.'
|
||||
);
|
||||
|
||||
while ($request->pager->to() >= self::ELASTIC_RESULTS_LIMIT) {
|
||||
$request->pager->number--;
|
||||
}
|
||||
}
|
||||
|
||||
$productsSearch = $this->elasticaFactory->createSearch();
|
||||
$productsSearch->setQuery($this->createMainQuery($request));
|
||||
|
||||
$multiSearch->addSearch($productsSearch, key: self::$mainQueryName);
|
||||
|
||||
foreach ($this->createFilterQueries($request) as $filterName => $filterQuery) {
|
||||
if (!$filterQuery->getAggregations()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$filterSearch = $this->elasticaFactory->createSearch();
|
||||
$filterSearch->setQuery($filterQuery);
|
||||
|
||||
$multiSearch->addSearch($filterSearch, key: $filterName);
|
||||
}
|
||||
|
||||
$shouldCreateBaseSearch = $request->fetchAllFilters && $this->isFiltered($request->baseFilterParams, $request->filterParams);
|
||||
|
||||
if ($shouldCreateBaseSearch) {
|
||||
$baseSearchQuery = $this->createBaseFiltersQuery($request);
|
||||
|
||||
if ($baseSearchQuery->getAggregations()) {
|
||||
$baseSearch = $this->elasticaFactory->createSearch();
|
||||
$baseSearch->setQuery($baseSearchQuery);
|
||||
|
||||
$multiSearch->addSearch($baseSearch, key: self::$baseQueryName);
|
||||
}
|
||||
}
|
||||
|
||||
return $multiSearch;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Query a filtered and paged query with product IDs and <i>sometimes :)</i> filter aggregations
|
||||
*/
|
||||
private function createMainQuery(SectionRequest $request): Query
|
||||
{
|
||||
$mainQuery = $this->elasticaFactory->createBoolQuery($filtersQuery = new BoolQuery());
|
||||
|
||||
// add all filters (with both base and dynamic & indexed)
|
||||
foreach ($this->elasticSpecs->getFilters($request->filterParams) as $filterName => $filter) {
|
||||
if ($filterName === Search::filterIdentifier()) {
|
||||
$filtersQuery->addMust($filter);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$filtersQuery->addFilter($filter);
|
||||
}
|
||||
|
||||
$allActiveFilters = FilterUtil::getActiveFilters(
|
||||
filterParams: $request->filterParams,
|
||||
attributes: $request->dynamicFilterAttributes,
|
||||
);
|
||||
|
||||
$aggregations = $this->elasticSpecs->getAggregations(
|
||||
$request->dynamicFilterAttributes,
|
||||
$request->filterParams,
|
||||
);
|
||||
foreach ($aggregations as $aggregationName => $aggregation) {
|
||||
$willCreateOwnSearch = false;
|
||||
|
||||
foreach ($allActiveFilters as $activeFilterName) {
|
||||
if ($activeFilterName === $aggregationName || str_starts_with($aggregationName, "{$activeFilterName}.")) {
|
||||
$willCreateOwnSearch = true;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ($willCreateOwnSearch) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$mainQuery->addAggregation($aggregation);
|
||||
}
|
||||
|
||||
// Explicit order from (GET parameter; or graphql field)
|
||||
if ($request->orderBy) {
|
||||
$this->elasticSpecs->applyOrderBy($mainQuery, $request->orderBy, $request->orderDir);
|
||||
}
|
||||
|
||||
// The fulltext search _score needs to be explicitly defined when
|
||||
// using "sort" in the ES search.
|
||||
if (in_array('search', $allActiveFilters)) {
|
||||
$mainQuery->addSort('_score');
|
||||
}
|
||||
|
||||
// Only apply default sort when the search filter is inactive.
|
||||
// It's a micro-optimization..... but the default sort usually generates
|
||||
// a sort script in order to combine all the order by options together -> potentially slow.
|
||||
//
|
||||
// The search hits will almost always have different scores,
|
||||
// in which case the default sort would not do anything.
|
||||
elseif (!$request->orderBy && $request->categorySort) {
|
||||
$this->elasticSpecs->applyDefaultSort($mainQuery, $request);
|
||||
}
|
||||
|
||||
if ($pager = $request->pager) {
|
||||
$this->elasticSpecs->applyPagination($mainQuery, $pager);
|
||||
} else {
|
||||
// If pager is not provided, set a default limit.
|
||||
// The ES default is 10, which is too low for most use cases.
|
||||
$mainQuery->setSize(static::$maxResults);
|
||||
}
|
||||
|
||||
return $mainQuery;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, Query> a list of queries with aggregations, one for each filter
|
||||
* (always containing all aggregations for all filters except for the current one)
|
||||
*/
|
||||
private function createFilterQueries(SectionRequest $request): array
|
||||
{
|
||||
// !only dynamic_filter! - hence the inclusion of baseFilterParams
|
||||
$activeFilters = FilterUtil::getActiveFilters(
|
||||
filterParams: $request->filterParams,
|
||||
attributes: $request->dynamicFilterAttributes,
|
||||
baseFilterParams: $request->baseFilterParams,
|
||||
);
|
||||
|
||||
$queries = [];
|
||||
|
||||
foreach ($activeFilters as $filterName) {
|
||||
$queryForFilter = $this->elasticaFactory->createBoolQuery(
|
||||
$partialQueryFilters = new BoolQuery(),
|
||||
aggregationsOnly: true,
|
||||
);
|
||||
|
||||
$aggregations = $this->elasticSpecs->getAggregations(
|
||||
$request->dynamicFilterAttributes,
|
||||
$request->filterParams,
|
||||
only: $filterName,
|
||||
);
|
||||
if (empty($aggregations)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($aggregations as $aggregation) {
|
||||
$queryForFilter->addAggregation($aggregation);
|
||||
}
|
||||
|
||||
foreach ($this->elasticSpecs->getFilters($request->filterParams, without: $filterName) as $filter) {
|
||||
$partialQueryFilters->addFilter($filter);
|
||||
}
|
||||
|
||||
$queries[$filterName] = $queryForFilter;
|
||||
}
|
||||
|
||||
return $queries;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Query A query with only aggregations for all allowed dynamic_filters
|
||||
*/
|
||||
private function createBaseFiltersQuery(SectionRequest $request): Query
|
||||
{
|
||||
$baseQuery = $this->elasticaFactory->createBoolQuery($filters = new BoolQuery(), aggregationsOnly: true);
|
||||
foreach ($this->elasticSpecs->getFilters($request->baseFilterParams) as $filter) {
|
||||
$filters->addFilter($filter);
|
||||
}
|
||||
|
||||
$baseFilter = FilterUtil::getActiveFilters(
|
||||
filterParams: $request->baseFilterParams,
|
||||
attributes: $request->dynamicFilterAttributes,
|
||||
);
|
||||
|
||||
foreach ($this->elasticSpecs->getAggregations($request->dynamicFilterAttributes, $request->filterParams) as $aggregationName => $aggregation) {
|
||||
if (in_array($aggregationName, $baseFilter)
|
||||
|| !$this->filterValuesCompiler->isAggregationAllowedAsBase($aggregationName, $aggregation)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$baseQuery->addAggregation($aggregation);
|
||||
}
|
||||
|
||||
return $baseQuery;
|
||||
}
|
||||
|
||||
private function isFiltered(\FilterParams $base, \FilterParams $all): bool
|
||||
{
|
||||
$baseActive = array_filter($base->getParams(), fn ($value) => !empty($value));
|
||||
$allActive = array_filter($all->getParams(), fn ($value) => !empty($value));
|
||||
|
||||
return !empty(array_diff_key($allActive, $baseActive));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user