Files
kupshop/bundles/KupShop/CatalogElasticBundle/PreparedSectionQuery.php
2025-08-02 16:30:27 +02:00

314 lines
9.9 KiB
PHP

<?php
declare(strict_types=1);
namespace KupShop\CatalogElasticBundle;
use Elastica\Exception\NotFoundException;
use Elastica\Multi\ResultSet as MultiResultSet;
use Elastica\Multi\Search as MultiSearch;
use Elastica\Result;
use Elastica\ResultSet;
use KupShop\CatalogBundle\ProductList\Builder\FilteredProductListInterface;
use KupShop\CatalogBundle\ProductList\DynamicFilterAttributes;
use KupShop\CatalogBundle\Search\Exception\FulltextException;
use KupShop\CatalogElasticBundle\Contracts\ElasticSectionEventDispatcher;
use KupShop\CatalogElasticBundle\Contracts\SectionResponseInterface;
use KupShop\CatalogElasticBundle\Dto\PreparedSectionQueryInnerState;
use KupShop\CatalogElasticBundle\Elastica\ErrorWrap;
use KupShop\CatalogElasticBundle\Util\ElasticFilterValues;
use KupShop\CatalogElasticBundle\Util\ElasticQueryClient;
use Query\Product;
use Query\QueryBuilder;
class PreparedSectionQuery implements SectionResponseInterface, FilteredProductListInterface, ElasticSectionEventDispatcher
{
private array $listeners = [];
private MultiResultSet $response;
private bool $isExecuted = false;
private float $queryCompileTime = -1;
private float $queryExecuteTime = -1;
/**
* @param \Closure(SectionRequest): MultiSearch $queryCompiler
*/
public function __construct(
private readonly SectionRequest $request,
private readonly \Closure $queryCompiler,
private readonly ElasticFilterValues $filterValuesCompiler,
private readonly \ProductList $productList,
private readonly \Filter $filter,
private readonly ?\Pager $pager,
private readonly DynamicFilterAttributes $dynamicFilterAttributes,
) {
}
/**
* Creates the ES search and executes it. If the request was already executed - do nothing.
*
* Also runs event listeners.
*
* @throws FulltextException
*/
protected function ensureExecuted(): void
{
if ($this->isExecuted) {
return;
}
// First execute the ES query.
// compile the query -> create the Elasticsearch MultiSearch from \FilterParams, \Pager etc.
// queryCompileTime = PHP execution time of the compilation of the ES query (through Elastica).
$compileStart = microtime(true);
// Provide access to the internal members of this class without needing to first execute the query (access by getters on this class).
// Some modules will need this to modify the \Pager (e.g. ProductListBanners - by adding PagerExtraItems into \Pager...).
$innerStateRef = new PreparedSectionQueryInnerState(
$this->productList,
$this->filter,
$this->pager,
$this->dynamicFilterAttributes,
);
// Send an event before the search is compiled - allows modifications to \FilterParams, \Pager, order etc.
$this->dispatchEvent(self::EVENT_BEFORE_COMPILE, $this->request, $innerStateRef);
// This line creates the ES search (in the form of Elastica\MultiSearch instance).
$multiSearch = $this->createMultiSearch();
$this->queryCompileTime = microtime(true) - $compileStart;
// execute it -> actually make the request to Elasticsearch
// queryExecuteTime = PHP execution time of the Elasticsearch (HTTP) request.
$executeStart = microtime(true);
// This event should be used to add additional searches to the MultiSearch.
// (You can also modify the existing ones, but don't do that please.)
// SearchView: adds searches to different ES indices to search in - articles, sections, pages, ...
$this->dispatchEvent(self::EVENT_BEFORE_EXECUTE, $multiSearch, $innerStateRef);
// Here the query is executed (HTTP request is sent).
$this->response = static::checkResponse($multiSearch->search());
$this->queryExecuteTime = microtime(true) - $executeStart;
$this->isExecuted = true;
// Fill the objects.
$productIds = $this->getProductIds();
$this->productList->andSpec(static function (QueryBuilder $qb) use ($productIds) {
$qb->andWhere(Product::productsIds($productIds));
});
// Add products counts for filters (\Filter)
$filterValuesCache = $this->filterValuesCompiler->getFromResponse(
$this->response,
$this->dynamicFilterAttributes,
);
$this->filter->setValuesCache($filterValuesCache);
// Update pager
if ($this->pager) {
$maxResultsAllowed = ElasticQueryClient::ELASTIC_RESULTS_LIMIT;
$this->pager->setTotal(min(
$maxResultsAllowed - ($maxResultsAllowed % $this->pager->getOnPage()),
$this->getProductsCount(),
));
}
$this->dispatchEvent(self::EVENT_AFTER_EXECUTE, $innerStateRef);
// Log the query timings as HTTP headers (when in development)
$this->debugLog();
}
public function createMultiSearch(): MultiSearch
{
return ($this->queryCompiler)($this->request);
}
public function getMainResult(): ResultSet
{
$this->ensureExecuted();
return $this->response[ElasticQueryClient::$mainQueryName];
}
public function getProductsCount(): int
{
return $this->getMainResult()->getTotalHits();
}
public function getProductIds(): array
{
return array_filter(array_map(
static fn (Result $doc) => $doc->getFields()['id'][0] ?? null,
$this->getMainResult()->getResults(),
));
}
public function getResponse(): MultiResultSet
{
$this->ensureExecuted();
return $this->response;
}
public function isExecuted(): bool
{
return $this->isExecuted;
}
public function getProductList(): \ProductList
{
$this->ensureExecuted();
return $this->productList;
}
public function getPager(): ?\Pager
{
$this->ensureExecuted();
return $this->pager;
}
public function getFilterParams(): \FilterParams
{
$this->ensureExecuted();
return $this->filter->getFilterParams();
}
public function getDynamicFilter(): \Filter
{
$this->ensureExecuted();
return $this->filter;
}
public function getQueryTimes(): array
{
$this->ensureExecuted();
$separate = [];
foreach ($this->response->getResultSets() as $name => $rs) {
try {
$separate[$name] = $rs->getResponse()->getEngineTime();
} catch (NotFoundException) {
$separate[$name] = '!NOT FOUND!';
}
}
try {
$total = $this->response->getResponse()->getEngineTime();
} catch (NotFoundException) {
$total = '!NOT FOUND!';
}
return [
'took-total' => $total,
'took-per-search' => $separate,
'php-compile' => $this->queryCompileTime,
'php-request' => $this->queryExecuteTime,
];
}
/**
* @param callable(SectionRequest, PreparedSectionQueryInnerState): void $listener
*/
public function onBeforeCompile(callable $listener): void
{
if ($this->isExecuted) {
throw new \LogicException(sprintf(
'Cannot add a "%s" listener after the search query was already executed (doing so would have no effect).',
self::EVENT_BEFORE_COMPILE,
));
}
$this->listeners[self::EVENT_BEFORE_COMPILE][] = $listener;
}
/**
* @param callable(MultiSearch, PreparedSectionQueryInnerState): void $listener
*/
public function onBeforeExecute(callable $listener): void
{
if ($this->isExecuted) {
throw new \LogicException(sprintf(
'Cannot add a "%s" listener after the search query was already executed (doing so would have no effect).',
self::EVENT_BEFORE_EXECUTE,
));
}
$this->listeners[self::EVENT_BEFORE_EXECUTE][] = $listener;
}
/**
* @param callable(PreparedSectionQueryInnerState): void $listener
*/
public function onAfterExecute(callable $listener): void
{
if ($this->isExecuted) {
throw new \LogicException(sprintf(
'Cannot add a "%s" listener after the search query was already executed (doing so would have no effect).',
self::EVENT_AFTER_EXECUTE,
));
}
$this->listeners[self::EVENT_AFTER_EXECUTE][] = $listener;
}
protected function dispatchEvent(string $eventName, ...$args): void
{
if (empty($this->listeners[$eventName])) {
return;
}
$listeners = &$this->listeners[$eventName];
while ($listener = array_shift($listeners)) {
$listener(...$args);
}
}
public function toArray(): array
{
return [
'productList' => $this->getProductList(),
'dynamicFilter' => $this->getDynamicFilter(),
'filterParams' => $this->getFilterParams(),
'pager' => $this->getPager(),
];
}
private function debugLog(): void
{
if (!isDevelopment() || isFunctionalTests()) {
return;
}
foreach ($this->getQueryTimes() as $key => $value) {
if (is_array($value)) {
$value = json_encode($value);
}
header("X-ES-{$key}: {$value}");
}
}
/**
* Check whether there are any errors in any of the result sets.
* The Elastica library unfortunately does not with Multi\ResultSet :/.
*
* @throws FulltextException
*/
protected static function checkResponse(MultiResultSet $multiResultSet): MultiResultSet
{
if ($err = ErrorWrap::extractError($multiResultSet)) {
getRaven()->captureException($err);
}
return $multiResultSet;
}
}