diff --git a/src/Decorator/ProductIndexFieldsProvider.php b/src/Decorator/ProductIndexFieldsProvider.php index e4d2c2b..361957f 100644 --- a/src/Decorator/ProductIndexFieldsProvider.php +++ b/src/Decorator/ProductIndexFieldsProvider.php @@ -14,12 +14,15 @@ namespace Gally\OroPlugin\Decorator; +use Gally\OroPlugin\Engine\SearchEngine; use Oro\Bundle\ProductBundle\Search\ProductIndexAttributeProviderInterface; +use Oro\Bundle\SearchBundle\Engine\EngineParameters; class ProductIndexFieldsProvider implements ProductIndexAttributeProviderInterface { public function __construct( private ProductIndexAttributeProviderInterface $productIndexAttributeProvider, + private EngineParameters $engineParameters, ) { } @@ -30,7 +33,7 @@ public function addForceIndexed(string $field): void public function isForceIndexed(string $field): bool { - // Todo check search engine - return true || $this->productIndexAttributeProvider->isForceIndexed($field); + return SearchEngine::ENGINE_NAME === $this->engineParameters->getEngineName() + || $this->productIndexAttributeProvider->isForceIndexed($field); } } diff --git a/src/Engine/ExpressionVisitor.php b/src/Engine/ExpressionVisitor.php index f7b4e18..c1f3a52 100644 --- a/src/Engine/ExpressionVisitor.php +++ b/src/Engine/ExpressionVisitor.php @@ -60,7 +60,7 @@ public function walkComparison(Comparison $comparison): ?array } if ('category_path' === $field) { - $this->currentCategoryId = 'node_' . basename(str_replace('_', '/', $value)); +// $this->currentCategoryId = 'node_' . basename(str_replace('_', '/', $value)); // todo this is wrong, the current category should contain content node id ! return null; } diff --git a/src/Engine/SearchEngine.php b/src/Engine/SearchEngine.php index f1ef959..0701193 100644 --- a/src/Engine/SearchEngine.php +++ b/src/Engine/SearchEngine.php @@ -42,6 +42,7 @@ public function __construct( private SearchManager $searchManager, private GallyRequestBuilder $requestBuilder, private SearchRegistry $registry, + private array $attributeMapping, ) { parent::__construct($eventDispatcher, $queryPlaceholderResolver, $mappingProvider); } @@ -60,9 +61,10 @@ protected function doSearch(Query $query, array $context = []) $results = []; foreach ($response->getCollection() as $item) { $item['id'] = (int) basename($item['id']); - $item['system_entity_id'] = $item['id']; // todo manage attributes - $item['names'] = $item['name']; - $item['descriptions'] = $item['description'] ?? ''; + + foreach ($this->attributeMapping as $oroAttribute => $gallyAttribute) { + $item[$oroAttribute] = $item[$gallyAttribute] ?? null; + } $results[] = new Item( 'product', // Todo manage other entity diff --git a/src/Extension/GallyDataGridExtension.php b/src/Extension/GallyDataGridExtension.php index cac8632..adc1d52 100644 --- a/src/Extension/GallyDataGridExtension.php +++ b/src/Extension/GallyDataGridExtension.php @@ -14,6 +14,7 @@ namespace Gally\OroPlugin\Extension; +use Gally\OroPlugin\Engine\SearchEngine; use Gally\OroPlugin\Registry\SearchRegistry; use Gally\Sdk\Entity\Metadata; use Gally\Sdk\Entity\SourceField; @@ -25,6 +26,7 @@ use Oro\Bundle\DataGridBundle\Extension\AbstractExtension; use Oro\Bundle\EntityExtendBundle\Form\Util\EnumTypeHelper; use Oro\Bundle\ProductBundle\Entity\Product; +use Oro\Bundle\SearchBundle\Engine\EngineParameters; /** * Adapt data grid for result managed by Gally. @@ -32,6 +34,7 @@ class GallyDataGridExtension extends AbstractExtension { public function __construct( + private EngineParameters $engineParameters, private SearchManager $searchManager, private SearchRegistry $registry, private EnumTypeHelper $enumTypeHelper, @@ -40,8 +43,8 @@ public function __construct( public function isApplicable(DatagridConfiguration $config): bool { - // Todo it is gally search engine - return 'frontend-product-search-grid' === $config->getName(); + return SearchEngine::ENGINE_NAME === $this->engineParameters->getEngineName() + && 'frontend-product-search-grid' === $config->getName(); } public function visitDatasource(DatagridConfiguration $config, DatasourceInterface $datasource) diff --git a/src/Resources/config/services/indexer.yml b/src/Resources/config/services/indexer.yml index 06f2647..9c383e2 100644 --- a/src/Resources/config/services/indexer.yml +++ b/src/Resources/config/services/indexer.yml @@ -3,6 +3,7 @@ services: decorates: oro_product.provider.index_fields arguments: - '@.inner' + - '@oro_website_search.engine.parameters' Gally\OroPlugin\Indexer\Provider\CatalogProvider: arguments: diff --git a/src/Resources/config/services/sdk.yml b/src/Resources/config/services/sdk.yml index 8c1dcef..a7832d9 100644 --- a/src/Resources/config/services/sdk.yml +++ b/src/Resources/config/services/sdk.yml @@ -1,6 +1,7 @@ services: Gally\Sdk\Client\Configuration: factory: ['\Gally\OroPlugin\Factory\ConfigurationFactory', 'create'] + lazy: true arguments: - '@oro_website_search.engine.parameters' diff --git a/src/Resources/config/services/search.yml b/src/Resources/config/services/search.yml index 1f49879..306080f 100644 --- a/src/Resources/config/services/search.yml +++ b/src/Resources/config/services/search.yml @@ -26,6 +26,7 @@ services: - '@Gally\Sdk\Service\SearchManager' - '@Gally\OroPlugin\RequestBuilder\GallyRequestBuilder' - '@Gally\OroPlugin\Registry\SearchRegistry' + - '%gally_config.attribute_mapping%' calls: - ['setMapper', ['@oro_website_search.engine.mapper']] tags: @@ -33,6 +34,7 @@ services: Gally\OroPlugin\Extension\GallyDataGridExtension: arguments: + - '@oro_website_search.engine.parameters' - '@Gally\Sdk\Service\SearchManager' - '@Gally\OroPlugin\Registry\SearchRegistry' - '@oro_entity_extend.enum_type_helper' diff --git a/src/Search/ContextProvider.php b/src/Search/ContextProvider.php new file mode 100644 index 0000000..9573268 --- /dev/null +++ b/src/Search/ContextProvider.php @@ -0,0 +1,59 @@ +<?php +/** + * DISCLAIMER + * + * Do not edit or add to this file if you wish to upgrade Gally to newer versions in the future. + * + * @package Gally + * @author Gally Team <elasticsuite@smile.fr> + * @copyright 2024-present Smile + * @license Open Software License v. 3.0 (OSL-3.0) + */ + +declare(strict_types=1); + +namespace Gally\OroPlugin\Search; + +use Gally\OroPlugin\Indexer\Provider\CatalogProvider; +use Gally\Sdk\Entity\LocalizedCatalog; +use Oro\Bundle\CatalogBundle\Handler\RequestProductHandler; +use Oro\Bundle\LocaleBundle\Entity\Localization; +use Oro\Bundle\LocaleBundle\Helper\LocalizationHelper; +use Oro\Bundle\WebCatalogBundle\Entity\ContentNode; +use Oro\Bundle\WebCatalogBundle\Provider\RequestWebContentVariantProvider; +use Oro\Bundle\WebsiteBundle\Entity\Website; +use Oro\Bundle\WebsiteBundle\Manager\WebsiteManager; + +class ContextProvider +{ + public function __construct( + private WebsiteManager $websiteManager, + private LocalizationHelper $localizationHelper, + private CatalogProvider $catalogProvider, + private RequestWebContentVariantProvider $requestWebContentVariantProvider, + ) { + } + + public function getCurrentWebsite(): Website + { + return $this->websiteManager->getCurrentWebsite(); + } + + public function getCurrentLocalization(): Localization + { + return $this->localizationHelper->getCurrentLocalization(); + } + + public function getCurrentLocalizedCatalog(): LocalizedCatalog + { + return $this->catalogProvider->buildLocalizedCatalog( + $this->getCurrentWebsite(), + $this->getCurrentLocalization(), + ); + } + + public function getCurrentContentNode(): ?ContentNode + { + return $this->requestWebContentVariantProvider->getContentVariant()?->getNode(); + } +} diff --git a/src/Search/ExpressionVisitor.php b/src/Search/ExpressionVisitor.php new file mode 100644 index 0000000..cceceb9 --- /dev/null +++ b/src/Search/ExpressionVisitor.php @@ -0,0 +1,128 @@ +<?php +/** + * DISCLAIMER + * + * Do not edit or add to this file if you wish to upgrade Gally to newer versions in the future. + * + * @package Gally + * @author Gally Team <elasticsuite@smile.fr> + * @copyright 2024-present Smile + * @license Open Software License v. 3.0 (OSL-3.0) + */ + +declare(strict_types=1); + +namespace Gally\OroPlugin\Search; + +use Doctrine\Common\Collections\Expr\Comparison; +use Doctrine\Common\Collections\Expr\CompositeExpression; +use Doctrine\Common\Collections\Expr\Expression; +use Doctrine\Common\Collections\Expr\ExpressionVisitor as BaseExpressionVisitor; +use Doctrine\Common\Collections\Expr\Value; +use Gally\Sdk\GraphQl\Request; +use Oro\Bundle\SearchBundle\Query\Criteria\Criteria; + +class ExpressionVisitor extends BaseExpressionVisitor +{ + private ?string $searchQuery = null; + private ?string $currentCategoryId = null; + + public function dispatch(Expression $expr, bool $isMainQuery = true) + { + // Use main query parameter to flatten main and expression. + + switch (true) { + case $expr instanceof Comparison: + return $this->walkComparison($expr); + case $expr instanceof Value: + return $this->walkValue($expr); + case $expr instanceof CompositeExpression: + return $this->walkCompositeExpression($expr, $isMainQuery); + default: + throw new \RuntimeException('Unknown Expression ' . $expr::class); + } + } + + public function walkCompositeExpression(CompositeExpression $expr, bool $isMainQuery = true): array + { + $type = '_must'; + if (CompositeExpression::TYPE_AND !== $expr->getType()) { + $isMainQuery = false; + $type = '_should'; + } + + $filters = []; + foreach ($expr->getExpressionList() as $expression) { + $filters[] = $this->dispatch($expression, $isMainQuery); + } + $filters = array_values(array_filter($filters)); + + return $isMainQuery + ? array_merge(...$filters) + : ['boolFilter' => [$type => $filters]]; + } + + public function walkComparison(Comparison $comparison): ?array + { + [$type, $field] = Criteria::explodeFieldTypeName($comparison->getField()); + $value = $this->dispatch($comparison->getValue()); + $operator = match ($comparison->getOperator()) { + 'IN' => Request::FILTER_OPERATOR_IN, + 'LIKE' => Request::FILTER_OPERATOR_MATCH, + default => Request::FILTER_OPERATOR_EQ, + // todo add EXISTS + }; + + if ('all_text' === $field) { + $this->searchQuery = $value; + + return null; + } + + if ('inv_status' === $field) { + $field = 'stock.status'; + if (count($value) > 1) { + return null; // if we want in stock and out of sotck product, we do not need this filter. + } + $operator = Request::FILTER_OPERATOR_EQ; + $value = in_array(\Oro\Bundle\ProductBundle\Entity\Product::INVENTORY_STATUS_IN_STOCK, $value, true); + // return null; //todo mange specificque code for code stock + } elseif (str_starts_with($field, 'visibility_customer.')) { + [$field, $value] = explode('.', $field); + } + + if ('category_path' === $field) { + // $this->currentCategoryId = 'node_' . basename(str_replace('_', '/', $value)); // todo this is wrong, the current category should contain content node id ! + + // return null; + } + + if (str_starts_with($field, 'assigned_to')) { + return null; // Todo manage this + } + if (str_starts_with($field, 'manually_added_to')) { + return null; // Todo manage this + } + + if (str_starts_with($field, 'category_path')) { + // return null; + } + + return [$field => [$operator => $value]]; + } + + public function walkValue(Value $value): mixed + { + return $value->getValue(); + } + + public function getCurrentCategoryId(): ?string + { + return $this->currentCategoryId; + } + + public function getSearchQuery(): ?string + { + return $this->searchQuery; + } +} diff --git a/src/Search/GallyRequestBuilder.php b/src/Search/GallyRequestBuilder.php new file mode 100644 index 0000000..99902cf --- /dev/null +++ b/src/Search/GallyRequestBuilder.php @@ -0,0 +1,132 @@ +<?php +/** + * DISCLAIMER + * + * Do not edit or add to this file if you wish to upgrade Gally to newer versions in the future. + * + * @package Gally + * @author Gally Team <elasticsuite@smile.fr> + * @copyright 2024-present Smile + * @license Open Software License v. 3.0 (OSL-3.0) + */ + +declare(strict_types=1); + +namespace Gally\OroPlugin\Search; + +use Gally\Sdk\GraphQl\Request; +use Oro\Bundle\SearchBundle\Query\Criteria\Criteria; +use Oro\Bundle\SearchBundle\Query\Query; + +class GallyRequestBuilder +{ + public function __construct( + private ContextProvider $contextProvider + ) { + } + + public function build(Query $query, array $context): Request + { + if (!$this->isProductQuery($query)) { + // Todo ! + throw new \Exception('Todo manage this'); + } + + [$currentPage, $pageSize] = $this->getPaginationInfo($query); + [$sortField, $sortDirection] = $this->getSortInfo($query); + [$searchQuery, $filters] = $this->getFilters($query); + $currentContentNode = $this->contextProvider->getCurrentContentNode(); + + return new Request( + $this->contextProvider->getCurrentLocalizedCatalog(), + $this->getSelectedFields($query), + $currentPage, + $pageSize, + $currentContentNode ? (string) $currentContentNode->getId() : null, + $searchQuery, + $filters, + $sortField, + $sortDirection, + ); + } + + private function isProductQuery(Query $query): bool + { + $from = $query->getFrom(); + + return 1 === \count($from) && str_starts_with($from[0], 'oro_product'); + } + + private function getSelectedFields(Query $query): array + { + // Todo Clean field name + $fields = $query->getSelect(); + $selectedFields = empty($fields) ? [] : ['id']; + foreach ($fields as $field) { + [$type, $name] = Criteria::explodeFieldTypeName($field); + if ('names' === $name) { + $name = 'name'; + } + if ('system_entity_id' === $name) { + $name = 'id'; + } + if ('inv_status' == $name) { // todo + continue; + } + $selectedFields[] = $name; + } + + return $selectedFields; + } + + /** + * @return array{0: int, 1: int} + */ + private function getPaginationInfo(Query $query): array + { + $from = (int) $query->getCriteria()->getFirstResult(); + $pageSize = (int) $query->getCriteria()->getMaxResults() ?: 25; + $currentPage = (int) ceil($from / $pageSize) + 1; + + return [$currentPage, $pageSize]; + } + + /** + * @return array{0: ?string, 1: ?string} + */ + private function getSortInfo(Query $query): array + { + $orders = $query->getCriteria()->getOrderings(); + + if (!empty($orders)) { + // We can only use one sort order in gally (score and id ordering are added automatically). + $order = array_key_first($orders); + [$type, $field] = Criteria::explodeFieldTypeName($order); + + if ('category_sort_order' == $field || str_starts_with($field, 'assigned_to_sort_order.')) { + // todo manage this globally + $field = 'category__position'; + $field = '_score'; + } + + return [$field, 'ASC' === $orders[$order] ? Request::SORT_DIRECTION_ASC : Request::SORT_DIRECTION_DESC]; + } + + return [null, null]; + } + + /** + * @return array{0: ?string, 1: ?string, 2: array} + */ + private function getFilters(Query $query): array + { + $visitor = new ExpressionVisitor(); + $filters = []; + + if ($expression = $query->getCriteria()->getWhereExpression()) { + $filters = $visitor->dispatch($expression); + } + + return [$visitor->getSearchQuery(), [$filters]]; + } +} diff --git a/src/Voter/ElasticSearchEngineFeatureVoter.php b/src/Voter/ElasticSearchEngineFeatureVoter.php index fc4a3ef..61f1bfd 100644 --- a/src/Voter/ElasticSearchEngineFeatureVoter.php +++ b/src/Voter/ElasticSearchEngineFeatureVoter.php @@ -49,6 +49,10 @@ public function vote($feature, $scopeIdentifier = null) return self::FEATURE_DISABLED; } + if ('saved_search' === $feature) { // Todo + return self::FEATURE_DISABLED; + } + return VoterInterface::FEATURE_ABSTAIN; } }