|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 sometimes :) 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 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)); } }