first commit

This commit is contained in:
2025-08-02 16:30:27 +02:00
commit 23646bfcee
14851 changed files with 1750626 additions and 0 deletions

View 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));
}
}