diff --git a/vendor/magento/module-catalog/Model/Indexer/Category/Product/AbstractAction.php b/vendor/magento/module-catalog/Model/Indexer/Category/Product/AbstractAction.php index 020c19578f7..00717e9cd99 100644 --- a/vendor/magento/module-catalog/Model/Indexer/Category/Product/AbstractAction.php +++ b/vendor/magento/module-catalog/Model/Indexer/Category/Product/AbstractAction.php @@ -29,23 +29,23 @@ abstract class AbstractAction /** * Chunk size */ - const RANGE_CATEGORY_STEP = 500; + public const RANGE_CATEGORY_STEP = 500; /** * Chunk size for product */ - const RANGE_PRODUCT_STEP = 1000000; + public const RANGE_PRODUCT_STEP = 1000000; /** * Catalog category index table name */ - const MAIN_INDEX_TABLE = 'catalog_category_product_index'; + public const MAIN_INDEX_TABLE = 'catalog_category_product_index'; /** * Suffix for table to show it is temporary * @deprecated see getIndexTable */ - const TEMPORARY_TABLE_SUFFIX = '_tmp'; + public const TEMPORARY_TABLE_SUFFIX = '_tmp'; /** * Cached non anchor categories select by store id @@ -582,7 +582,7 @@ abstract class AbstractAction 'category_id' => 'cc.entity_id', 'product_id' => 'ccp.product_id', 'position' => new \Zend_Db_Expr( - $this->connection->getIfNullSql('ccp2.position', 'ccp.position + 10000') + $this->connection->getIfNullSql('ccp2.position', 'MIN(ccp.position) + 10000') ), 'is_parent' => new \Zend_Db_Expr('0'), 'store_id' => new \Zend_Db_Expr($store->getId()), @@ -823,7 +823,7 @@ abstract class AbstractAction 'category_id' => new \Zend_Db_Expr($store->getRootCategoryId()), 'product_id' => 'cp.entity_id', 'position' => new \Zend_Db_Expr( - $this->connection->getCheckSql('ccp.product_id IS NOT NULL', 'ccp.position', '0') + $this->connection->getCheckSql('ccp.product_id IS NOT NULL', 'MIN(ccp.position)', '10000') ), 'is_parent' => new \Zend_Db_Expr( $this->connection->getCheckSql('ccp.product_id IS NOT NULL', '1', '0') diff --git a/vendor/magento/module-catalog/Model/ResourceModel/Product/Collection/JoinMinimalPosition.php b/vendor/magento/module-catalog/Model/ResourceModel/Product/Collection/JoinMinimalPosition.php new file mode 100644 index 00000000000..66ecc5a87d1 --- /dev/null +++ b/vendor/magento/module-catalog/Model/ResourceModel/Product/Collection/JoinMinimalPosition.php @@ -0,0 +1,109 @@ +tableMaintainer = $tableMaintainer; + } + + /** + * Add minimal position to the collection select + * + * @param Collection $collection + * @param array $categoryIds + * @return void + * @throws \Magento\Framework\Exception\LocalizedException + * @throws \Zend_Db_Select_Exception + */ + public function execute(Collection $collection, array $categoryIds): void + { + $positions = []; + $connection = $collection->getConnection(); + $select = $collection->getSelect(); + + foreach ($categoryIds as $categoryId) { + $table = 'cat_index_' . $categoryId; + $conditions = [ + $table . '.product_id=e.entity_id', + $connection->quoteInto( + $table . '.store_id=?', + $collection->getStoreId(), + 'int' + ), + $connection->quoteInto( + $table . '.category_id=?', + $categoryId, + 'int' + ) + ]; + + $joinCond = implode(' AND ', $conditions); + $fromPart = $select->getPart(Select::FROM); + if (isset($fromPart[$table])) { + $fromPart[$table]['joinCondition'] = $joinCond; + $select->setPart(Select::FROM, $fromPart); + } else { + $select->joinLeft( + [$table => $this->tableMaintainer->getMainTable($collection->getStoreId())], + $joinCond, + [] + ); + } + $positions[] = $connection->getIfNullSql($table . '.position', '~0'); + } + + // Ensures that position attribute is registered in _joinFields + // in order for sort by position to use cat_index_position field + $collection->addExpressionAttributeToSelect('position', 'cat_index_position', 'entity_id'); + + $columns = $select->getPart(Select::COLUMNS); + $preparedColumns = []; + $columnFound = false; + $minPos = $connection->getLeastSql($positions); + + // Remove columns with alias cat_index_position + // Find column entry that was added in addExpressionAttributeToSelect. Expected [, cat_index_position, position] + // and replace it with [, LEAST(...), cat_index_position] + foreach ($columns as $columnEntry) { + if ($columnEntry[2] !== 'cat_index_position') { + if ($columnEntry[2] === 'position' && $columnEntry[1] === 'cat_index_position') { + if (!$columnFound) { + $columnEntry[1] = $minPos; + $columnEntry[2] = 'cat_index_position'; + $columnFound = true; + } else { + continue; + } + } + $preparedColumns[] = $columnEntry; + } + } + + $select->setPart(Select::COLUMNS, $preparedColumns); + + if (!$columnFound) { + $select->columns(['cat_index_position' => $minPos]); + } + } +} diff --git a/vendor/magento/module-catalog-graph-ql/DataProvider/Product/SearchCriteriaBuilder.php b/vendor/magento/module-catalog-graph-ql/DataProvider/Product/SearchCriteriaBuilder.php index 52443172db2..46431861303 100644 --- a/vendor/magento/module-catalog-graph-ql/DataProvider/Product/SearchCriteriaBuilder.php +++ b/vendor/magento/module-catalog-graph-ql/DataProvider/Product/SearchCriteriaBuilder.php @@ -8,18 +8,25 @@ declare(strict_types=1); namespace Magento\CatalogGraphQl\DataProvider\Product; use Magento\Catalog\Api\Data\EavAttributeInterface; +use Magento\Catalog\Model\Product; +use Magento\Catalog\Model\Product\Visibility; +use Magento\Eav\Model\Config; use Magento\Framework\Api\FilterBuilder; use Magento\Framework\Api\Search\FilterGroupBuilder; use Magento\Framework\Api\Search\SearchCriteriaInterface; use Magento\Framework\Api\SortOrder; +use Magento\Framework\Api\SortOrderBuilder; use Magento\Framework\App\Config\ScopeConfigInterface; +use Magento\Framework\App\ObjectManager; +use Magento\Framework\Exception\LocalizedException; use Magento\Framework\GraphQl\Query\Resolver\Argument\SearchCriteria\Builder; -use Magento\Catalog\Model\Product\Visibility; -use Magento\Framework\Api\SortOrderBuilder; +use Magento\Framework\Search\Request\Config as SearchConfig; /** * Build search criteria + * @SuppressWarnings(PHPMD.CouplingBetweenObjects) */ + class SearchCriteriaBuilder { /** @@ -41,6 +48,7 @@ class SearchCriteriaBuilder * @var Builder */ private $builder; + /** * @var Visibility */ @@ -51,13 +59,25 @@ class SearchCriteriaBuilder */ private $sortOrderBuilder; + /** + * @var Config + */ + private Config $eavConfig; + + /** + * @var SearchConfig + */ + private SearchConfig $searchConfig; + /** * @param Builder $builder * @param ScopeConfigInterface $scopeConfig * @param FilterBuilder $filterBuilder * @param FilterGroupBuilder $filterGroupBuilder * @param Visibility $visibility - * @param SortOrderBuilder $sortOrderBuilder + * @param SortOrderBuilder|null $sortOrderBuilder + * @param Config|null $eavConfig + * @param SearchConfig|null $searchConfig */ public function __construct( Builder $builder, @@ -65,14 +85,18 @@ class SearchCriteriaBuilder FilterBuilder $filterBuilder, FilterGroupBuilder $filterGroupBuilder, Visibility $visibility, - SortOrderBuilder $sortOrderBuilder + SortOrderBuilder $sortOrderBuilder = null, + Config $eavConfig = null, + SearchConfig $searchConfig = null ) { $this->scopeConfig = $scopeConfig; $this->filterBuilder = $filterBuilder; $this->filterGroupBuilder = $filterGroupBuilder; $this->builder = $builder; $this->visibility = $visibility; - $this->sortOrderBuilder = $sortOrderBuilder; + $this->sortOrderBuilder = $sortOrderBuilder ?? ObjectManager::getInstance()->get(SortOrderBuilder::class); + $this->eavConfig = $eavConfig ?? ObjectManager::getInstance()->get(Config::class); + $this->searchConfig = $searchConfig ?? ObjectManager::getInstance()->get(SearchConfig::class); } /** @@ -81,21 +105,35 @@ class SearchCriteriaBuilder * @param array $args * @param bool $includeAggregation * @return SearchCriteriaInterface + * @throws LocalizedException */ public function build(array $args, bool $includeAggregation): SearchCriteriaInterface { + $partialMatchFilters = []; + if (isset($args['filter'])) { + $partialMatchFilters = $this->getPartialMatchFilters($args); + $args = $this->removeMatchTypeFromArguments($args); + } $searchCriteria = $this->builder->build('products', $args); $isSearch = !empty($args['search']); $this->updateRangeFilters($searchCriteria); - if ($includeAggregation) { - $this->preparePriceAggregation($searchCriteria); + $attributeData = $this->eavConfig->getAttribute(Product::ENTITY, 'price'); + $priceOptions = $attributeData->getData(); + + if ($priceOptions['is_filterable'] != 0) { + $this->preparePriceAggregation($searchCriteria); + } $requestName = 'graphql_product_search_with_aggregation'; } else { $requestName = 'graphql_product_search'; } $searchCriteria->setRequestName($requestName); + if (count($partialMatchFilters)) { + $this->updateMatchTypeRequestConfig($requestName, $partialMatchFilters); + } + if ($isSearch) { $this->addFilter($searchCriteria, 'search_term', $args['search']); } @@ -104,7 +142,7 @@ class SearchCriteriaBuilder $this->addDefaultSortOrder($searchCriteria, $args, $isSearch); } - $this->addEntityIdSort($searchCriteria, $args); + $this->addEntityIdSort($searchCriteria); $this->addVisibilityFilter($searchCriteria, $isSearch, !empty($args['filter'])); $searchCriteria->setCurrentPage($args['currentPage']); @@ -113,6 +151,63 @@ class SearchCriteriaBuilder return $searchCriteria; } + /** + * Update dynamically the search match type based on requested params + * + * @param string $requestName + * @param array $partialMatchFilters + * + * @return void + */ + private function updateMatchTypeRequestConfig(string $requestName, array $partialMatchFilters): void + { + $data = $this->searchConfig->get($requestName); + foreach ($data['queries'] as $queryName => $query) { + foreach ($query['match'] ?? [] as $index => $matchItem) { + if (in_array($matchItem['field'] ?? null, $partialMatchFilters, true)) { + $data['queries'][$queryName]['match'][$index]['matchCondition'] = 'match_phrase_prefix'; + } + } + } + $this->searchConfig->merge([$requestName => $data]); + } + + /** + * Check if and what type of match_type value was requested + * + * @param array $args + * + * @return array + */ + private function getPartialMatchFilters(array $args): array + { + $partialMatchFilters = []; + foreach ($args['filter'] as $fieldName => $conditions) { + if (isset($conditions['match_type']) && $conditions['match_type'] === 'PARTIAL') { + $partialMatchFilters[] = $fieldName; + } + } + return $partialMatchFilters; + } + + /** + * Remove the match_type to avoid search criteria containing it + * + * @param array $args + * + * @return array + */ + private function removeMatchTypeFromArguments(array $args): array + { + foreach ($args['filter'] as &$conditions) { + if (isset($conditions['match_type'])) { + unset($conditions['match_type']); + } + } + + return $args; + } + /** * Add filter by visibility * @@ -137,15 +232,22 @@ class SearchCriteriaBuilder * Add sort by Entity ID * * @param SearchCriteriaInterface $searchCriteria - * @param array $args */ - private function addEntityIdSort(SearchCriteriaInterface $searchCriteria, array $args): void + private function addEntityIdSort(SearchCriteriaInterface $searchCriteria): void { - $sortOrder = !empty($args['sort']) ? reset($args['sort']) : SortOrder::SORT_DESC; $sortOrderArray = $searchCriteria->getSortOrders(); + $sortDir = SortOrder::SORT_DESC; + if (is_array($sortOrderArray) && count($sortOrderArray) > 0) { + $sortOrder = end($sortOrderArray); + // in the case the last sort order is by position, sort IDs in descendent order + $sortDir = $sortOrder->getField() === EavAttributeInterface::POSITION + ? SortOrder::SORT_DESC + : $sortOrder->getDirection(); + } + $sortOrderArray[] = $this->sortOrderBuilder ->setField('_id') - ->setDirection($sortOrder) + ->setDirection($sortDir) ->create(); $searchCriteria->setSortOrders($sortOrderArray); } diff --git a/vendor/magento/module-catalog-graph-ql/Model/Resolver/Products/SearchCriteria/CollectionProcessor/FilterProcessor/CategoryFilter.php b/vendor/magento/module-catalog-graph-ql/Model/Resolver/Products/SearchCriteria/CollectionProcessor/FilterProcessor/CategoryFilter.php index b03d1bd068b..d15c072b9fe 100644 --- a/vendor/magento/module-catalog-graph-ql/Model/Resolver/Products/SearchCriteria/CollectionProcessor/FilterProcessor/CategoryFilter.php +++ b/vendor/magento/module-catalog-graph-ql/Model/Resolver/Products/SearchCriteria/CollectionProcessor/FilterProcessor/CategoryFilter.php @@ -10,8 +10,10 @@ namespace Magento\CatalogGraphQl\Model\Resolver\Products\SearchCriteria\Collecti use Magento\Catalog\Model\CategoryFactory; use Magento\Catalog\Model\ResourceModel\Category as CategoryResourceModel; use Magento\Catalog\Model\ResourceModel\Product\Collection; +use Magento\Catalog\Model\ResourceModel\Product\Collection\JoinMinimalPosition; use Magento\Framework\Api\Filter; use Magento\Framework\Api\SearchCriteria\CollectionProcessor\FilterProcessor\CustomFilterInterface; +use Magento\Framework\App\ObjectManager; use Magento\Framework\Data\Collection\AbstractDb; /** @@ -59,16 +61,25 @@ class CategoryFilter implements CustomFilterInterface */ private $categoryResourceModel; + /** + * @var JoinMinimalPosition + */ + private $joinMinimalPosition; + /** * @param CategoryFactory $categoryFactory * @param CategoryResourceModel $categoryResourceModel + * @param JoinMinimalPosition|null $joinMinimalPosition */ public function __construct( CategoryFactory $categoryFactory, - CategoryResourceModel $categoryResourceModel + CategoryResourceModel $categoryResourceModel, + ?JoinMinimalPosition $joinMinimalPosition = null ) { $this->categoryFactory = $categoryFactory; $this->categoryResourceModel = $categoryResourceModel; + $this->joinMinimalPosition = $joinMinimalPosition + ?? ObjectManager::getInstance()->get(JoinMinimalPosition::class); } /** @@ -82,35 +93,66 @@ class CategoryFilter implements CustomFilterInterface */ public function apply(Filter $filter, AbstractDb $collection) { - $conditionType = $filter->getConditionType() ?: self::CONDITION_TYPE_IN; - $value = $filter->getValue(); - if ($value && in_array($conditionType, self::CONDITION_TYPES)) { - if ($conditionType === self::CONDITION_TYPE_EQ) { - $category = $this->getCategory((int) $value); - /** @var Collection $collection */ + if ($this->isApplicable($filter)) { + /** @var Collection $collection */ + $conditionType = $filter->getConditionType() ?: self::CONDITION_TYPE_IN; + $value = $filter->getValue(); + $ids = is_array($value) ? $value : explode(',', (string) $value); + if (in_array($conditionType, [self::CONDITION_TYPE_EQ, self::CONDITION_TYPE_IN]) && count($ids) === 1) { + $category = $this->getCategory((int) reset($ids)); /** This filter adds ability to sort by position*/ $collection->addCategoryFilter($category); - } elseif (!$collection->getFlag('search_resut_applied')) { - /** Prevent filtering duplication as the filter should be already applied to the search result */ - $values = is_array($value) ? $value : explode(',', (string) $value); - $categoryIds = []; - foreach ($values as $value) { - $category = $this->getCategory((int) $value); - $children = []; - $childrenStr = $category->getIsAnchor() ? $category->getChildren(true) : ''; - if ($childrenStr) { - $children = explode(',', $childrenStr); - } - array_push($categoryIds, $value, ...$children); - } - /** @var Collection $collection */ - $collection->addCategoriesFilter([$conditionType => array_map('intval', $categoryIds)]); + } elseif ($conditionType === self::CONDITION_TYPE_IN) { + $this->joinMinimalPosition->execute($collection, $ids); + } + /** Prevent filtering duplication as the filter should be already applied to the search result */ + if (!$collection->getFlag('search_resut_applied')) { + $collection->addCategoriesFilter( + [ + $conditionType => array_map('intval', $this->getCategoryIds($ids)) + ] + ); } } return true; } + /** + * Check whether the filter can be applied + * + * @param Filter $filter + * @return bool + */ + private function isApplicable(Filter $filter): bool + { + /** @var Collection $collection */ + $conditionType = $filter->getConditionType() ?: self::CONDITION_TYPE_IN; + + return $filter->getValue() && in_array($conditionType, self::CONDITION_TYPES); + } + + /** + * Returns all children category IDs for anchor categories including the provided categories + * + * @param array $values + * @return array + */ + private function getCategoryIds(array $values): array + { + $categoryIds = []; + foreach ($values as $value) { + $category = $this->getCategory((int) $value); + $children = []; + $childrenStr = $category->getIsAnchor() ? $category->getChildren(true) : ''; + if ($childrenStr) { + $children = explode(',', $childrenStr); + } + array_push($categoryIds, $value, ...$children); + } + return $categoryIds; + } + /** * Retrieve the category model by ID * diff --git a/vendor/magento/module-graph-ql/etc/schema.graphqls b/vendor/magento/module-graph-ql/etc/schema.graphqls index 1ba190cd8bb..8870a94847d 100644 --- a/vendor/magento/module-graph-ql/etc/schema.graphqls +++ b/vendor/magento/module-graph-ql/etc/schema.graphqls @@ -77,6 +77,12 @@ input FilterRangeTypeInput @doc(description: "Defines a filter that matches a ra input FilterMatchTypeInput @doc(description: "Defines a filter that performs a fuzzy search.") { match: String @doc(description: "Use this attribute to exactly match the specified string. For example, to filter on a specific SKU, specify a value such as `24-MB01`.") + match_type: FilterMatchTypeEnum @doc(description: "Filter match type for fine-tuned results. Possible values FULL or PARTIAL. If match_type is not provided, returned results will default to FULL match.") +} + +enum FilterMatchTypeEnum { + FULL + PARTIAL } input FilterStringTypeInput @doc(description: "Defines a filter for an input string.") {