diff --git a/src/Component/Arguments/AbstractPaginatorArgumentsBuilder.php b/src/Component/Arguments/AbstractPaginatorArgumentsBuilder.php new file mode 100644 index 0000000000..6442f230cb --- /dev/null +++ b/src/Component/Arguments/AbstractPaginatorArgumentsBuilder.php @@ -0,0 +1,32 @@ + [ + 'type' => 'String', + ], + 'first' => [ + 'type' => 'Int', + ], + 'before' => [ + 'type' => 'String', + ], + 'last' => [ + 'type' => 'Int', + ], + ]; + } +} diff --git a/src/Component/Arguments/PaginatorArgumentsBuilder.php b/src/Component/Arguments/AbstractProductPaginatorArgumentsBuilder.php similarity index 61% rename from src/Component/Arguments/PaginatorArgumentsBuilder.php rename to src/Component/Arguments/AbstractProductPaginatorArgumentsBuilder.php index a3a7b5934b..22898f8e5d 100644 --- a/src/Component/Arguments/PaginatorArgumentsBuilder.php +++ b/src/Component/Arguments/AbstractProductPaginatorArgumentsBuilder.php @@ -4,10 +4,9 @@ namespace Shopsys\FrontendApiBundle\Component\Arguments; -use Overblog\GraphQLBundle\Definition\Builder\MappingInterface; use Shopsys\FrontendApiBundle\Component\Arguments\Exception\MandatoryArgumentMissingException; -class PaginatorArgumentsBuilder implements MappingInterface +class AbstractProductPaginatorArgumentsBuilder extends AbstractPaginatorArgumentsBuilder { protected const CONFIG_ORDER_TYPE_KEY = 'orderingModeType'; @@ -19,38 +18,17 @@ public function toMappingDefinition(array $config): array { $this->checkMandatoryFields($config); - return [ - 'after' => [ - 'type' => 'String', - ], - 'first' => [ - 'type' => 'Int', - ], - 'before' => [ - 'type' => 'String', - ], - 'last' => [ - 'type' => 'Int', - ], + $mappingDefinition = parent::toMappingDefinition($config); + + return array_merge($mappingDefinition, [ 'orderingMode' => [ 'type' => $config[static::CONFIG_ORDER_TYPE_KEY], ], 'filter' => [ 'type' => 'ProductFilter', + 'validation' => 'cascade', ], - 'search' => [ - 'type' => 'String', - ], - 'categorySlug' => [ - 'type' => 'String', - ], - 'brandSlug' => [ - 'type' => 'String', - ], - 'flagSlug' => [ - 'type' => 'String', - ], - ]; + ]); } /** diff --git a/src/Component/Arguments/BlogArticlePaginatorArgumentsBuilder.php b/src/Component/Arguments/BlogArticlePaginatorArgumentsBuilder.php index c62729fb9a..73487e9ad1 100644 --- a/src/Component/Arguments/BlogArticlePaginatorArgumentsBuilder.php +++ b/src/Component/Arguments/BlogArticlePaginatorArgumentsBuilder.php @@ -4,9 +4,7 @@ namespace Shopsys\FrontendApiBundle\Component\Arguments; -use Overblog\GraphQLBundle\Definition\Builder\MappingInterface; - -class BlogArticlePaginatorArgumentsBuilder implements MappingInterface +class BlogArticlePaginatorArgumentsBuilder extends AbstractPaginatorArgumentsBuilder { /** * @param array $config @@ -14,23 +12,13 @@ class BlogArticlePaginatorArgumentsBuilder implements MappingInterface */ public function toMappingDefinition(array $config): array { - return [ - 'after' => [ - 'type' => 'String', - ], - 'first' => [ - 'type' => 'Int', - ], - 'before' => [ - 'type' => 'String', - ], - 'last' => [ - 'type' => 'Int', - ], + $mappingDefinition = parent::toMappingDefinition($config); + + return array_merge($mappingDefinition, [ 'onlyHomepageArticles' => [ 'type' => 'Boolean', 'defaultValue' => false, ], - ]; + ]); } } diff --git a/src/Component/Arguments/ProductPaginatorArgumentsBuilder.php b/src/Component/Arguments/ProductPaginatorArgumentsBuilder.php new file mode 100644 index 0000000000..b4f6247c67 --- /dev/null +++ b/src/Component/Arguments/ProductPaginatorArgumentsBuilder.php @@ -0,0 +1,31 @@ +checkMandatoryFields($config); + + $mappingDefinition = parent::toMappingDefinition($config); + + return array_merge($mappingDefinition, [ + 'categorySlug' => [ + 'type' => 'String', + ], + 'brandSlug' => [ + 'type' => 'String', + ], + 'flagSlug' => [ + 'type' => 'String', + ], + ]); + } +} diff --git a/src/Component/Arguments/ProductSearchPaginatorArgumentsBuilder.php b/src/Component/Arguments/ProductSearchPaginatorArgumentsBuilder.php new file mode 100644 index 0000000000..59bc32407a --- /dev/null +++ b/src/Component/Arguments/ProductSearchPaginatorArgumentsBuilder.php @@ -0,0 +1,25 @@ +checkMandatoryFields($config); + + $mappingDefinition = parent::toMappingDefinition($config); + + return array_merge($mappingDefinition, [ + 'search' => [ + 'type' => 'String', + ], + ]); + } +} diff --git a/src/DependencyInjection/RegisterProductsSearchResultsProvidersCompilerPass.php b/src/DependencyInjection/RegisterProductsSearchResultsProvidersCompilerPass.php new file mode 100644 index 0000000000..9b2ab1c067 --- /dev/null +++ b/src/DependencyInjection/RegisterProductsSearchResultsProvidersCompilerPass.php @@ -0,0 +1,44 @@ +getDefinition(ProductSearchResultsProviderResolver::class); + $productSearchResultsProvidersDefinitions = $container->findTaggedServiceIds('shopsys.frontend_api.products_search_results_provider'); + + foreach ($productSearchResultsProvidersDefinitions as $serviceId => $tags) { + $priority = null; + + foreach ($tags as $tag) { + if (array_key_exists('priority', $tag)) { + $priority = $tag['priority']; + } + } + + if (!is_int($priority)) { + throw new ProductSearchResultsProviderPriorityNotSetException(sprintf('Service "%s" has not defined required tag priority or its type is not integer.', $serviceId)); + } + + $productSearchResultsProviderResolverDefinition->addMethodCall( + 'registerProductSearchResultsProvider', + [ + $serviceId, + $priority, + ], + ); + } + } +} diff --git a/src/Model/Blog/Article/BlogArticlesQuery.php b/src/Model/Blog/Article/BlogArticlesQuery.php index 3956d19298..ec45a2100d 100644 --- a/src/Model/Blog/Article/BlogArticlesQuery.php +++ b/src/Model/Blog/Article/BlogArticlesQuery.php @@ -13,8 +13,6 @@ class BlogArticlesQuery extends AbstractQuery { - protected const DEFAULT_FIRST_LIMIT = 10; - /** * @param \Shopsys\FrameworkBundle\Model\Blog\Article\Elasticsearch\BlogArticleElasticsearchFacade $blogArticleElasticsearchFacade */ @@ -57,16 +55,4 @@ public function blogArticleByCategoryQuery(Argument $argument, BlogCategory $blo return $paginator->auto($argument, $this->blogArticleElasticsearchFacade->getByBlogCategoryTotalCount($blogCategory)); } - - /** - * @param \Overblog\GraphQLBundle\Definition\Argument $argument - */ - protected function setDefaultFirstOffsetIfNecessary(Argument $argument): void - { - if ($argument->offsetExists('first') === false - && $argument->offsetExists('last') === false - ) { - $argument->offsetSet('first', static::DEFAULT_FIRST_LIMIT); - } - } } diff --git a/src/Model/Product/Connection/ProductConnection.php b/src/Model/Product/Connection/ProductConnection.php index ae48fc6dff..761d09eaed 100644 --- a/src/Model/Product/Connection/ProductConnection.php +++ b/src/Model/Product/Connection/ProductConnection.php @@ -4,33 +4,31 @@ namespace Shopsys\FrontendApiBundle\Model\Product\Connection; +use Closure; use Overblog\GraphQLBundle\Relay\Connection\Output\Connection; use Overblog\GraphQLBundle\Relay\Connection\PageInfoInterface; +use Shopsys\FrameworkBundle\Model\Product\Listing\ProductListOrderingConfig; use Shopsys\FrontendApiBundle\Model\Product\Filter\ProductFilterOptions; class ProductConnection extends Connection { - /** - * @var callable - */ - protected $productFilterOptionsClosure; - /** * @param \Overblog\GraphQLBundle\Relay\Connection\EdgeInterface[] $edges * @param \Overblog\GraphQLBundle\Relay\Connection\PageInfoInterface|null $pageInfo - * @param int $totalCount - * @param callable $productFilterOptionsClosure + * @param \Closure $productFilterOptionsClosure + * @param string|null $orderingMode + * @param int|null $totalCount + * @param string $defaultOrderingMode */ public function __construct( array $edges, ?PageInfoInterface $pageInfo, - int $totalCount, - callable $productFilterOptionsClosure, + protected readonly Closure $productFilterOptionsClosure, + protected readonly ?string $orderingMode = null, + protected $totalCount = null, + protected readonly string $defaultOrderingMode = ProductListOrderingConfig::ORDER_BY_PRIORITY, ) { parent::__construct($edges, $pageInfo); - - $this->totalCount = $totalCount; - $this->productFilterOptionsClosure = $productFilterOptionsClosure; } /** @@ -40,4 +38,20 @@ public function getProductFilterOptions(): ProductFilterOptions { return ($this->productFilterOptionsClosure)(); } + + /** + * @return string|null + */ + public function getOrderingMode(): ?string + { + return $this->orderingMode; + } + + /** + * @return string + */ + public function getDefaultOrderingMode(): string + { + return $this->defaultOrderingMode; + } } diff --git a/src/Model/Product/Connection/ProductConnectionFactory.php b/src/Model/Product/Connection/ProductConnectionFactory.php index bf4f7c71da..d61f746bc6 100644 --- a/src/Model/Product/Connection/ProductConnectionFactory.php +++ b/src/Model/Product/Connection/ProductConnectionFactory.php @@ -4,7 +4,9 @@ namespace Shopsys\FrontendApiBundle\Model\Product\Connection; +use Closure; use Overblog\GraphQLBundle\Definition\Argument; +use Overblog\GraphQLBundle\Relay\Connection\ConnectionBuilder; use Overblog\GraphQLBundle\Relay\Connection\Paginator; use Shopsys\FrameworkBundle\Model\Category\Category; use Shopsys\FrameworkBundle\Model\Product\Brand\Brand; @@ -28,14 +30,16 @@ public function __construct( * @param callable $retrieveProductClosure * @param int $countOfProducts * @param \Overblog\GraphQLBundle\Definition\Argument $argument - * @param callable $getProductFilterConfigClosure + * @param \Closure $getProductFilterConfigClosure + * @param string|null $orderingMode * @return \Shopsys\FrontendApiBundle\Model\Product\Connection\ProductConnection */ protected function createConnection( callable $retrieveProductClosure, int $countOfProducts, Argument $argument, - callable $getProductFilterConfigClosure, + Closure $getProductFilterConfigClosure, + ?string $orderingMode = null, ): ProductConnection { $paginator = new Paginator($retrieveProductClosure); $connection = $paginator->auto($argument, $countOfProducts); @@ -43,8 +47,9 @@ protected function createConnection( return new ProductConnection( $connection->getEdges(), $connection->getPageInfo(), - $connection->getTotalCount(), $getProductFilterConfigClosure, + $orderingMode, + $connection->getTotalCount(), ); } @@ -53,6 +58,7 @@ protected function createConnection( * @param int $countOfProducts * @param \Overblog\GraphQLBundle\Definition\Argument $argument * @param \Shopsys\FrameworkBundle\Model\Product\Filter\ProductFilterData $productFilterData + * @param string|null $orderingMode * @return \Shopsys\FrontendApiBundle\Model\Product\Connection\ProductConnection */ public function createConnectionForAll( @@ -60,19 +66,17 @@ public function createConnectionForAll( int $countOfProducts, Argument $argument, ProductFilterData $productFilterData, + ?string $orderingMode = null, ): ProductConnection { - $productFilterOptionsClosure = function () use ($productFilterData) { - return $this->productFilterOptionsFactory->createProductFilterOptionsForAll( - $this->productFilterFacade->getProductFilterConfigForAll(), - $productFilterData, - ); - }; + $searchText = $argument['search'] ?? ''; + $productFilterOptionsClosure = $this->getProductFilterOptionsClosure($productFilterData, $searchText); return $this->createConnection( $retrieveProductClosure, $countOfProducts, $argument, $productFilterOptionsClosure, + $orderingMode, ); } @@ -137,4 +141,61 @@ public function createConnectionForCategory( $productFilterOptionsClosure, ); } + + /** + * @param array $products + * @param string $search + * @param int $offset + * @param int $limit + * @param int $countOfProducts + * @param \Shopsys\FrameworkBundle\Model\Product\Filter\ProductFilterData $productFilterData + * @param string|null $orderingMode + * @return \Shopsys\FrontendApiBundle\Model\Product\Connection\ProductConnection + */ + public function createConnectionForSearchFromArray( + array $products, + string $search, + int $offset, + int $limit, + int $countOfProducts, + ProductFilterData $productFilterData, + ?string $orderingMode = null, + ): ProductConnection { + $connectionBuilder = new ConnectionBuilder(); + $connection = $connectionBuilder->connectionFromArray($products); + + $pageInfo = $connection->getPageInfo(); + $pageInfo->setHasPreviousPage($offset > 0); + $pageInfo->setHasNextPage($offset + $limit < $countOfProducts); + + return new ProductConnection( + $connection->getEdges(), + $pageInfo, + $this->getProductFilterOptionsClosure($productFilterData, $search), + $orderingMode, + $countOfProducts, + ); + } + + /** + * @param \Shopsys\FrameworkBundle\Model\Product\Filter\ProductFilterData $productFilterData + * @param mixed $searchText + * @return \Closure + */ + protected function getProductFilterOptionsClosure(ProductFilterData $productFilterData, mixed $searchText): Closure + { + return function () use ($productFilterData, $searchText) { + if ($searchText === '') { + $productFilterConfig = $this->productFilterFacade->getProductFilterConfigForAll(); + } else { + $productFilterConfig = $this->productFilterFacade->getProductFilterConfigForSearch($searchText); + } + + return $this->productFilterOptionsFactory->createProductFilterOptionsForAll( + $productFilterConfig, + $productFilterData, + $searchText, + ); + }; + } } diff --git a/src/Model/Product/Filter/ProductFilterDataMapper.php b/src/Model/Product/Filter/ProductFilterDataMapper.php index 3e1b76fd95..13203c2c56 100644 --- a/src/Model/Product/Filter/ProductFilterDataMapper.php +++ b/src/Model/Product/Filter/ProductFilterDataMapper.php @@ -7,6 +7,7 @@ use Shopsys\FrameworkBundle\Model\Product\Brand\BrandFacade; use Shopsys\FrameworkBundle\Model\Product\Filter\ParameterFilterData; use Shopsys\FrameworkBundle\Model\Product\Filter\ProductFilterData; +use Shopsys\FrameworkBundle\Model\Product\Filter\ProductFilterDataFactory; use Shopsys\FrameworkBundle\Model\Product\Flag\FlagFacade; use Shopsys\FrameworkBundle\Model\Product\Parameter\ParameterFacade; @@ -26,11 +27,13 @@ class ProductFilterDataMapper * @param \Shopsys\FrameworkBundle\Model\Product\Flag\FlagFacade $flagFacade * @param \Shopsys\FrameworkBundle\Model\Product\Brand\BrandFacade $brandFacade * @param \Shopsys\FrameworkBundle\Model\Product\Parameter\ParameterFacade $parameterFacade + * @param \Shopsys\FrameworkBundle\Model\Product\Filter\ProductFilterDataFactory $productFilterDataFactory */ public function __construct( protected readonly FlagFacade $flagFacade, protected readonly BrandFacade $brandFacade, protected readonly ParameterFacade $parameterFacade, + protected readonly ProductFilterDataFactory $productFilterDataFactory, ) { } @@ -40,7 +43,7 @@ public function __construct( */ public function mapFrontendApiFilterToProductFilterData(array $frontendApiFilter): ProductFilterData { - $productFilterData = new ProductFilterData(); + $productFilterData = $this->productFilterDataFactory->create(); $productFilterData->minimalPrice = $frontendApiFilter['minimalPrice'] ?? null; $productFilterData->maximalPrice = $frontendApiFilter['maximalPrice'] ?? null; $productFilterData->parameters = $this->getParametersAndValuesByUuids($frontendApiFilter['parameters'] ?? []); diff --git a/src/Model/Product/Filter/ProductFilterFacade.php b/src/Model/Product/Filter/ProductFilterFacade.php index 65ba349d40..3c454208a4 100644 --- a/src/Model/Product/Filter/ProductFilterFacade.php +++ b/src/Model/Product/Filter/ProductFilterFacade.php @@ -11,6 +11,7 @@ use Shopsys\FrameworkBundle\Model\Product\Filter\ProductFilterConfig; use Shopsys\FrameworkBundle\Model\Product\Filter\ProductFilterConfigFactory; use Shopsys\FrameworkBundle\Model\Product\Filter\ProductFilterData; +use Shopsys\FrameworkBundle\Model\Product\Filter\ProductFilterDataFactory; class ProductFilterFacade { @@ -24,12 +25,14 @@ class ProductFilterFacade * @param \Shopsys\FrontendApiBundle\Model\Product\Filter\ProductFilterDataMapper $productFilterDataMapper * @param \Shopsys\FrontendApiBundle\Model\Product\Filter\ProductFilterNormalizer $productFilterNormalizer * @param \Shopsys\FrameworkBundle\Model\Product\Filter\ProductFilterConfigFactory $productFilterConfigFactory + * @param \Shopsys\FrameworkBundle\Model\Product\Filter\ProductFilterDataFactory $productFilterDataFactory */ public function __construct( protected readonly Domain $domain, protected readonly ProductFilterDataMapper $productFilterDataMapper, protected readonly ProductFilterNormalizer $productFilterNormalizer, protected readonly ProductFilterConfigFactory $productFilterConfigFactory, + protected readonly ProductFilterDataFactory $productFilterDataFactory, ) { } @@ -71,18 +74,16 @@ public function getProductFilterConfigForBrand(Brand $brand): ProductFilterConfi /** * @param \Shopsys\FrameworkBundle\Model\Category\Category $category - * @param string $searchText * @return \Shopsys\FrameworkBundle\Model\Product\Filter\ProductFilterConfig */ - public function getProductFilterConfigForCategory(Category $category, string $searchText = ''): ProductFilterConfig + public function getProductFilterConfigForCategory(Category $category): ProductFilterConfig { - $cacheKey = 'category_' . $category->getId() . '_search_' . $searchText; + $cacheKey = 'category_' . $category->getId(); if (!array_key_exists($cacheKey, $this->productFilterConfigCache)) { $this->productFilterConfigCache[$cacheKey] = $this->productFilterConfigFactory->createForCategory( $this->domain->getLocale(), $category, - $searchText, ); } @@ -114,7 +115,7 @@ protected function getValidatedProductFilterData( public function getValidatedProductFilterDataForAll(Argument $argument): ProductFilterData { if ($argument['filter'] === null) { - return new ProductFilterData(); + return $this->productFilterDataFactory->create(); } $productFilterConfig = $this->getProductFilterConfigForAll(); @@ -130,7 +131,7 @@ public function getValidatedProductFilterDataForAll(Argument $argument): Product public function getValidatedProductFilterDataForCategory(Argument $argument, Category $category): ProductFilterData { if ($argument['filter'] === null) { - return new ProductFilterData(); + return $this->productFilterDataFactory->create(); } $productFilterConfig = $this->getProductFilterConfigForCategory($category); @@ -146,11 +147,30 @@ public function getValidatedProductFilterDataForCategory(Argument $argument, Cat public function getValidatedProductFilterDataForBrand(Argument $argument, Brand $brand): ProductFilterData { if ($argument['filter'] === null) { - return new ProductFilterData(); + return $this->productFilterDataFactory->create(); } $productFilterConfig = $this->getProductFilterConfigForBrand($brand); return $this->getValidatedProductFilterData($argument, $productFilterConfig); } + + /** + * @param string $searchText + * @return \Shopsys\FrameworkBundle\Model\Product\Filter\ProductFilterConfig + */ + public function getProductFilterConfigForSearch(string $searchText): ProductFilterConfig + { + $cacheKey = 'search_' . $searchText; + + if (!array_key_exists($cacheKey, $this->productFilterConfigCache)) { + $this->productFilterConfigCache[$cacheKey] = $this->productFilterConfigFactory->createForSearch( + $this->domain->getId(), + $this->domain->getLocale(), + $searchText, + ); + } + + return $this->productFilterConfigCache[$cacheKey]; + } } diff --git a/src/Model/Product/Filter/ProductFilterOptionsFactory.php b/src/Model/Product/Filter/ProductFilterOptionsFactory.php index f123227a73..d82d2c5305 100644 --- a/src/Model/Product/Filter/ProductFilterOptionsFactory.php +++ b/src/Model/Product/Filter/ProductFilterOptionsFactory.php @@ -112,19 +112,29 @@ protected function createProductFilterOptions( /** * @param \Shopsys\FrameworkBundle\Model\Product\Filter\ProductFilterConfig $productFilterConfig * @param \Shopsys\FrameworkBundle\Model\Product\Filter\ProductFilterData $productFilterData + * @param string $searchText * @return \Shopsys\FrontendApiBundle\Model\Product\Filter\ProductFilterOptions */ public function createProductFilterOptionsForAll( ProductFilterConfig $productFilterConfig, ProductFilterData $productFilterData, + string $searchText = '', ): ProductFilterOptions { if (!$this->moduleFacade->isEnabled(ModuleList::PRODUCT_FILTER_COUNTS)) { return $this->createProductFilterOptionsInstance(); } - $productFilterCountData = $this->productOnCurrentDomainElasticFacade->getProductFilterCountDataForAll( - $productFilterData, - ); + if ($searchText !== '') { + $productFilterCountData = $this->productOnCurrentDomainElasticFacade->getProductFilterCountDataForSearch( + $searchText, + $productFilterConfig, + $productFilterData, + ); + } else { + $productFilterCountData = $this->productOnCurrentDomainElasticFacade->getProductFilterCountDataForAll( + $productFilterData, + ); + } $productFilterOptions = $this->createProductFilterOptions( $productFilterConfig, diff --git a/src/Model/Product/ProductFacade.php b/src/Model/Product/ProductFacade.php index d698688ba3..42f192559c 100644 --- a/src/Model/Product/ProductFacade.php +++ b/src/Model/Product/ProductFacade.php @@ -42,8 +42,10 @@ public function getSellableByUuid(string $uuid, int $domainId, PricingGroup $pri * @param string $search * @return int */ - public function getFilteredProductsCountOnCurrentDomain(ProductFilterData $productFilterData, string $search): int - { + public function getFilteredProductsCountOnCurrentDomain( + ProductFilterData $productFilterData, + string $search = '', + ): int { $filterQuery = $this->filterQueryFactory->createListableWithProductFilter($productFilterData); if ($search !== '') { @@ -66,7 +68,7 @@ public function getFilteredProductsOnCurrentDomain( int $offset, string $orderingModeId, ProductFilterData $productFilterData, - string $search, + string $search = '', ): array { $filterQuery = $this->filterQueryFactory->createWithProductFilterData( $productFilterData, diff --git a/src/Model/Product/ProductRepository.php b/src/Model/Product/ProductRepository.php index 4656521043..f898d4f618 100644 --- a/src/Model/Product/ProductRepository.php +++ b/src/Model/Product/ProductRepository.php @@ -26,11 +26,11 @@ public function __construct(protected readonly FrameworkProductRepository $produ */ public function getSellableByUuid(string $uuid, int $domainId, PricingGroup $pricingGroup): Product { - $qb = $this->productRepository->getAllSellableQueryBuilder($domainId, $pricingGroup); - $qb->andWhere('p.uuid = :uuid'); - $qb->setParameter('uuid', $uuid); + $queryBuilder = $this->productRepository->getAllSellableQueryBuilder($domainId, $pricingGroup); + $queryBuilder->andWhere('p.uuid = :uuid'); + $queryBuilder->setParameter('uuid', $uuid); - $product = $qb->getQuery()->getOneOrNullResult(); + $product = $queryBuilder->getQuery()->getOneOrNullResult(); if ($product === null) { throw new ProductNotFoundException( diff --git a/src/Model/Resolver/AbstractQuery.php b/src/Model/Resolver/AbstractQuery.php index 0dbea543b0..0df874f073 100644 --- a/src/Model/Resolver/AbstractQuery.php +++ b/src/Model/Resolver/AbstractQuery.php @@ -4,6 +4,7 @@ namespace Shopsys\FrontendApiBundle\Model\Resolver; +use Overblog\GraphQLBundle\Definition\Argument; use Overblog\GraphQLBundle\Definition\Resolver\AliasedInterface; use Overblog\GraphQLBundle\Definition\Resolver\QueryInterface; use ReflectionClass; @@ -12,6 +13,7 @@ abstract class AbstractQuery implements AliasedInterface, QueryInterface { protected const QUERY_SUFFIX = 'Query'; + protected const DEFAULT_FIRST_LIMIT = 10; /** * @return array @@ -24,4 +26,14 @@ public static function getAliases(): array return array_combine($filteredMethodNames, $filteredMethodNames); } + + /** + * @param \Overblog\GraphQLBundle\Definition\Argument $argument + */ + protected function setDefaultFirstOffsetIfNecessary(Argument $argument): void + { + if ($argument->offsetExists('first') === false && $argument->offsetExists('last') === false) { + $argument->offsetSet('first', static::DEFAULT_FIRST_LIMIT); + } + } } diff --git a/src/Model/Resolver/Article/ArticlesQuery.php b/src/Model/Resolver/Article/ArticlesQuery.php index e0211abb58..44680c3a92 100644 --- a/src/Model/Resolver/Article/ArticlesQuery.php +++ b/src/Model/Resolver/Article/ArticlesQuery.php @@ -13,8 +13,6 @@ class ArticlesQuery extends AbstractQuery { - protected const DEFAULT_FIRST_LIMIT = 10; - /** * @param \Shopsys\FrameworkBundle\Component\Domain\Domain $domain * @param \Shopsys\FrameworkBundle\Model\Article\Elasticsearch\ArticleElasticsearchFacade $articleElasticsearchFacade @@ -41,16 +39,4 @@ public function articlesQuery(Argument $argument, array $placements) return $paginator->auto($argument, $this->articleElasticsearchFacade->getAllArticlesTotalCount($placements)); } - - /** - * @param \Overblog\GraphQLBundle\Definition\Argument $argument - */ - protected function setDefaultFirstOffsetIfNecessary(Argument $argument): void - { - if ($argument->offsetExists('first') === false - && $argument->offsetExists('last') === false - ) { - $argument->offsetSet('first', static::DEFAULT_FIRST_LIMIT); - } - } } diff --git a/src/Model/Resolver/Category/CategoriesSearchQuery.php b/src/Model/Resolver/Category/CategoriesSearchQuery.php index f6766096b0..87b0f193da 100644 --- a/src/Model/Resolver/Category/CategoriesSearchQuery.php +++ b/src/Model/Resolver/Category/CategoriesSearchQuery.php @@ -12,8 +12,6 @@ class CategoriesSearchQuery extends AbstractQuery { - protected const DEFAULT_FIRST_LIMIT = 10; - /** * @param \Shopsys\FrameworkBundle\Component\Domain\Domain $domain * @param \Shopsys\FrontendApiBundle\Model\Category\CategoryFacade $categoryFacade @@ -53,16 +51,4 @@ public function categoriesSearchQuery(Argument $argument) ), ); } - - /** - * @param \Overblog\GraphQLBundle\Definition\Argument $argument - */ - protected function setDefaultFirstOffsetIfNecessary(Argument $argument): void - { - if ($argument->offsetExists('first') === false - && $argument->offsetExists('last') === false - ) { - $argument->offsetSet('first', static::DEFAULT_FIRST_LIMIT); - } - } } diff --git a/src/Model/Resolver/Order/OrdersQuery.php b/src/Model/Resolver/Order/OrdersQuery.php index 48e0ff318b..87698992ed 100644 --- a/src/Model/Resolver/Order/OrdersQuery.php +++ b/src/Model/Resolver/Order/OrdersQuery.php @@ -13,8 +13,6 @@ class OrdersQuery extends AbstractQuery { - protected const DEFAULT_FIRST_LIMIT = 10; - /** * @param \Shopsys\FrameworkBundle\Model\Customer\User\CurrentCustomerUser $currentCustomerUser * @param \Shopsys\FrontendApiBundle\Model\Order\OrderApiFacade $orderApiFacade @@ -45,16 +43,4 @@ public function ordersQuery(Argument $argument) return $paginator->auto($argument, $this->orderApiFacade->getCustomerUserOrderCount($customerUser)); } - - /** - * @param \Overblog\GraphQLBundle\Definition\Argument $argument - */ - protected function setDefaultFirstOffsetIfNecessary(Argument $argument): void - { - if ($argument->offsetExists('first') === false - && $argument->offsetExists('last') === false - ) { - $argument->offsetSet('first', static::DEFAULT_FIRST_LIMIT); - } - } } diff --git a/src/Model/Resolver/Products/ProductOrderingModeProvider.php b/src/Model/Resolver/Products/ProductOrderingModeProvider.php new file mode 100644 index 0000000000..f55cfa4ed4 --- /dev/null +++ b/src/Model/Resolver/Products/ProductOrderingModeProvider.php @@ -0,0 +1,55 @@ +getDefaultOrderingMode($argument); + + if ($argument->offsetExists('orderingMode') && $argument->offsetGet('orderingMode') !== null) { + $orderingMode = $argument->offsetGet('orderingMode'); + } + + return $orderingMode; + } + + /** + * @param \Overblog\GraphQLBundle\Definition\Argument $argument + * @return string + */ + public function getDefaultOrderingMode(Argument $argument): string + { + if (isset($argument['search'])) { + return $this->getDefaultOrderingModeForSearch(); + } + + return $this->getDefaultOrderingModeForListing(); + } + + /** + * @return string + */ + public function getDefaultOrderingModeForListing(): string + { + return ProductListOrderingConfig::ORDER_BY_PRIORITY; + } + + /** + * @return string + */ + protected function getDefaultOrderingModeForSearch(): string + { + return ProductListOrderingConfig::ORDER_BY_RELEVANCE; + } +} diff --git a/src/Model/Resolver/Products/ProductsQuery.php b/src/Model/Resolver/Products/ProductsQuery.php index 5bd937bbf0..0213c42103 100644 --- a/src/Model/Resolver/Products/ProductsQuery.php +++ b/src/Model/Resolver/Products/ProductsQuery.php @@ -11,7 +11,6 @@ use Shopsys\FrameworkBundle\Model\Product\Brand\Brand; use Shopsys\FrameworkBundle\Model\Product\List\ProductList; use Shopsys\FrameworkBundle\Model\Product\List\ProductListFacade; -use Shopsys\FrameworkBundle\Model\Product\Listing\ProductListOrderingConfig; use Shopsys\FrameworkBundle\Model\Product\ProductRepository; use Shopsys\FrontendApiBundle\Model\Product\Connection\ProductConnectionFactory; use Shopsys\FrontendApiBundle\Model\Product\Filter\ProductFilterFacade; @@ -20,8 +19,6 @@ class ProductsQuery extends AbstractQuery { - protected const DEFAULT_FIRST_LIMIT = 10; - /** * @param \Shopsys\FrontendApiBundle\Model\Product\ProductFacade $productFacade * @param \Shopsys\FrontendApiBundle\Model\Product\Filter\ProductFilterFacade $productFilterFacade @@ -29,6 +26,7 @@ class ProductsQuery extends AbstractQuery * @param \Overblog\DataLoader\DataLoaderInterface $productsVisibleAndSortedByIdsBatchLoader * @param \Shopsys\FrameworkBundle\Model\Product\List\ProductListFacade $productListFacade * @param \Shopsys\FrameworkBundle\Model\Product\ProductRepository $productRepository + * @param \Shopsys\FrontendApiBundle\Model\Resolver\Products\ProductOrderingModeProvider $productOrderingModeProvider */ public function __construct( protected readonly ProductFacade $productFacade, @@ -37,6 +35,7 @@ public function __construct( protected readonly DataLoaderInterface $productsVisibleAndSortedByIdsBatchLoader, protected readonly ProductListFacade $productListFacade, protected readonly ProductRepository $productRepository, + protected readonly ProductOrderingModeProvider $productOrderingModeProvider, ) { } @@ -59,7 +58,7 @@ function ($offset, $limit) use ($argument, $productFilterData, $search) { return $this->productFacade->getFilteredProductsOnCurrentDomain( $limit, $offset, - $this->getOrderingModeFromArgument($argument), + $this->productOrderingModeProvider->getOrderingModeFromArgument($argument), $productFilterData, $search, ); @@ -93,7 +92,7 @@ function ($offset, $limit) use ($argument, $category, $productFilterData, $searc $category, $limit, $offset, - $this->getOrderingModeFromArgument($argument), + $this->productOrderingModeProvider->getOrderingModeFromArgument($argument), $productFilterData, $search, ); @@ -127,7 +126,7 @@ function ($offset, $limit) use ($argument, $brand, $productFilterData, $search) $brand, $limit, $offset, - $this->getOrderingModeFromArgument($argument), + $this->productOrderingModeProvider->getOrderingModeFromArgument($argument), $productFilterData, $search, ); @@ -138,44 +137,6 @@ function ($offset, $limit) use ($argument, $brand, $productFilterData, $search) ); } - /** - * @param \Overblog\GraphQLBundle\Definition\Argument $argument - */ - protected function setDefaultFirstOffsetIfNecessary(Argument $argument): void - { - if ($argument->offsetExists('first') === false && $argument->offsetExists('last') === false) { - $argument->offsetSet('first', static::DEFAULT_FIRST_LIMIT); - } - } - - /** - * @param \Overblog\GraphQLBundle\Definition\Argument $argument - * @return string - */ - protected function getOrderingModeFromArgument(Argument $argument): string - { - $orderingMode = $this->getDefaultOrderingMode($argument); - - if ($argument->offsetExists('orderingMode')) { - $orderingMode = $argument->offsetGet('orderingMode'); - } - - return $orderingMode; - } - - /** - * @param \Overblog\GraphQLBundle\Definition\Argument $argument - * @return string - */ - protected function getDefaultOrderingMode(Argument $argument): string - { - if (isset($argument['search'])) { - return ProductListOrderingConfig::ORDER_BY_RELEVANCE; - } - - return ProductListOrderingConfig::ORDER_BY_PRIORITY; - } - /** * @param \Shopsys\FrameworkBundle\Model\Product\List\ProductList $productList * @return \GraphQL\Executor\Promise\Promise diff --git a/src/Model/Resolver/Products/Search/Exception/NoProductSearchResultsProviderEnabledOnDomainException.php b/src/Model/Resolver/Products/Search/Exception/NoProductSearchResultsProviderEnabledOnDomainException.php new file mode 100644 index 0000000000..f8004a3139 --- /dev/null +++ b/src/Model/Resolver/Products/Search/Exception/NoProductSearchResultsProviderEnabledOnDomainException.php @@ -0,0 +1,18 @@ +setDefaultFirstOffsetIfNecessary($argument); + + $productFilterData = $this->productFilterFacade->getValidatedProductFilterDataForAll( + $argument, + ); + + $productSearchResultsProvider = $this->productSearchResultsProviderResolver->getProductsSearchResultsProviderByDomainId($this->domain->getId()); + + return $productSearchResultsProvider->getProductsSearchResults($argument, $productFilterData); + } +} diff --git a/src/Model/Resolver/Products/Search/ProductSearchResultsProvider.php b/src/Model/Resolver/Products/Search/ProductSearchResultsProvider.php new file mode 100644 index 0000000000..c888e95c27 --- /dev/null +++ b/src/Model/Resolver/Products/Search/ProductSearchResultsProvider.php @@ -0,0 +1,68 @@ +productConnectionFactory->createConnectionForAll( + function ($offset, $limit) use ($search, $productFilterData) { + return $this->productFacade->getFilteredProductsOnCurrentDomain( + $limit, + $offset, + ProductListOrderingConfig::ORDER_BY_RELEVANCE, + $productFilterData, + $search, + ); + }, + $this->productFacade->getFilteredProductsCountOnCurrentDomain($productFilterData, $search), + $argument, + $productFilterData, + $this->productOrderingModeProvider->getOrderingModeFromArgument($argument), + ); + } + + /** + * @param int $domainId + * @return bool + */ + public function isEnabledOnDomain(int $domainId): bool + { + return true; + } +} diff --git a/src/Model/Resolver/Products/Search/ProductSearchResultsProviderInterface.php b/src/Model/Resolver/Products/Search/ProductSearchResultsProviderInterface.php new file mode 100644 index 0000000000..27de7365d4 --- /dev/null +++ b/src/Model/Resolver/Products/Search/ProductSearchResultsProviderInterface.php @@ -0,0 +1,28 @@ + + */ + protected array $productSearchResultsProvidersServiceIdByPriority = []; + + /** + * @param \Shopsys\FrontendApiBundle\Model\Resolver\Products\Search\ProductSearchResultsProviderInterface[] $productSearchResultsProviders + */ + public function __construct( + protected readonly iterable $productSearchResultsProviders, + ) { + } + + /** + * @param int $domainId + * @return \Shopsys\FrontendApiBundle\Model\Resolver\Products\Search\ProductSearchResultsProviderInterface + */ + public function getProductsSearchResultsProviderByDomainId( + int $domainId, + ): ProductSearchResultsProviderInterface { + Assert::allIsInstanceOf($this->productSearchResultsProviders, ProductSearchResultsProviderInterface::class); + + foreach ($this->getProductsSearchResultsProvidersOrderedByPriority() as $productSearchResultsProvider) { + if ($productSearchResultsProvider->isEnabledOnDomain($domainId)) { + return $productSearchResultsProvider; + } + } + + throw new NoProductSearchResultsProviderEnabledOnDomainException($domainId); + } + + /** + * @return \Shopsys\FrontendApiBundle\Model\Resolver\Products\Search\ProductSearchResultsProviderInterface[] + */ + protected function getProductsSearchResultsProvidersOrderedByPriority(): array + { + krsort($this->productSearchResultsProvidersServiceIdByPriority, SORT_NUMERIC); + + $productSearchResultsProvidersOrderedByPriority = []; + + foreach ($this->productSearchResultsProvidersServiceIdByPriority as $serviceId) { + foreach ($this->productSearchResultsProviders as $productSearchResultsProvider) { + if ($productSearchResultsProvider instanceof $serviceId) { + $productSearchResultsProvidersOrderedByPriority[] = $productSearchResultsProvider; + } + } + } + + return $productSearchResultsProvidersOrderedByPriority; + } + + /** + * @param string $serviceId + * @param int $priority + */ + public function registerProductSearchResultsProvider(string $serviceId, int $priority): void + { + if (array_key_exists($priority, $this->productSearchResultsProvidersServiceIdByPriority)) { + throw new ProductSearchResultsProviderWithSamePriorityAlreadyExistsException($serviceId, $priority); + } + + $this->productSearchResultsProvidersServiceIdByPriority[$priority] = $serviceId; + } +} diff --git a/src/Resources/config/graphql-types/BrandDecorator.types.yaml b/src/Resources/config/graphql-types/BrandDecorator.types.yaml index 7997905f66..a09fbaa909 100644 --- a/src/Resources/config/graphql-types/BrandDecorator.types.yaml +++ b/src/Resources/config/graphql-types/BrandDecorator.types.yaml @@ -39,7 +39,7 @@ BrandDecorator: type: "ProductConnection" description: "Paginated and ordered products of brand" argsBuilder: - builder: "PaginatorArgumentsBuilder" + builder: "ProductPaginatorArgumentsBuilder" config: orderingModeType: 'ProductOrderingModeEnum' resolve: '@=query("productsByBrandQuery", args, value)' diff --git a/src/Resources/config/graphql-types/CategoryDecorator.types.yaml b/src/Resources/config/graphql-types/CategoryDecorator.types.yaml index afee2fcf1a..00f7a5cac0 100644 --- a/src/Resources/config/graphql-types/CategoryDecorator.types.yaml +++ b/src/Resources/config/graphql-types/CategoryDecorator.types.yaml @@ -28,7 +28,7 @@ CategoryDecorator: type: "ProductConnection" description: "Paginated and ordered products of category" argsBuilder: - builder: "PaginatorArgumentsBuilder" + builder: "ProductPaginatorArgumentsBuilder" config: orderingModeType: 'ProductOrderingModeEnum' resolve: '@=query("productsByCategoryQuery", args, value)' diff --git a/src/Resources/config/graphql-types/QueryDecorator.types.yaml b/src/Resources/config/graphql-types/QueryDecorator.types.yaml index 21eaeca1a1..5628be60b2 100644 --- a/src/Resources/config/graphql-types/QueryDecorator.types.yaml +++ b/src/Resources/config/graphql-types/QueryDecorator.types.yaml @@ -18,7 +18,7 @@ QueryDecorator: products: type: "ProductConnection" argsBuilder: - builder: "PaginatorArgumentsBuilder" + builder: "ProductPaginatorArgumentsBuilder" config: orderingModeType: 'ProductOrderingModeEnum' resolve: "@=query('productsQuery', args)" @@ -32,6 +32,18 @@ QueryDecorator: urlSlug: type: "String" description: "Returns product filtered using UUID or URL slug" + productsSearch: + type: "ProductConnection!" + argsBuilder: + builder: "ProductSearchPaginatorArgumentsBuilder" + config: + orderingModeType: 'ProductOrderingModeEnum' + resolve: "@=query('productsSearchQuery', args)" + complexity: "@=dynamicPaginationComplexity(args)" + args: + search: + type: "String!" + description: "Returns list of searched products that can be paginated using `first`, `last`, `before` and `after` keywords" RegularProduct: type: 'RegularProduct' MainVariant: diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml index 5931672f5b..a46811c426 100644 --- a/src/Resources/config/services.yaml +++ b/src/Resources/config/services.yaml @@ -54,3 +54,11 @@ services: Shopsys\FrontendApiBundle\Component\ExpressionLanguage\DynamicPaginationComplexityExpressionFunction: tags: [ 'overblog_graphql.expression_function' ] + + Shopsys\FrontendApiBundle\Model\Resolver\Products\Search\ProductSearchResultsProviderResolver: + arguments: + $productSearchResultsProviders: !tagged 'shopsys.frontend_api.products_search_results_provider' + + Shopsys\FrontendApiBundle\Model\Resolver\Products\Search\ProductSearchResultsProvider: + tags: + - { name: 'shopsys.frontend_api.products_search_results_provider', priority: 1 } diff --git a/src/ShopsysFrontendApiBundle.php b/src/ShopsysFrontendApiBundle.php index 40b28146e0..b2481ac3db 100644 --- a/src/ShopsysFrontendApiBundle.php +++ b/src/ShopsysFrontendApiBundle.php @@ -4,8 +4,19 @@ namespace Shopsys\FrontendApiBundle; +use Shopsys\FrontendApiBundle\DependencyInjection\RegisterProductsSearchResultsProvidersCompilerPass; +use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\HttpKernel\Bundle\Bundle; class ShopsysFrontendApiBundle extends Bundle { + /** + * @param \Symfony\Component\DependencyInjection\ContainerBuilder $container + */ + public function build(ContainerBuilder $container) + { + parent::build($container); + + $container->addCompilerPass(new RegisterProductsSearchResultsProvidersCompilerPass()); + } }