diff --git a/app/code/Magento/CatalogInventory/Model/Indexer/Stock/AbstractAction.php b/app/code/Magento/CatalogInventory/Model/Indexer/Stock/AbstractAction.php index 1e7c800ea1c38..85fee62eb4303 100644 --- a/app/code/Magento/CatalogInventory/Model/Indexer/Stock/AbstractAction.php +++ b/app/code/Magento/CatalogInventory/Model/Indexer/Stock/AbstractAction.php @@ -10,6 +10,7 @@ use Magento\Framework\App\ObjectManager; use Magento\Framework\App\ResourceConnection; +use Magento\Framework\EntityManager\MetadataPool; /** * Abstract action reindex class @@ -70,25 +71,33 @@ abstract class AbstractAction */ private $cacheCleaner; + /** + * @var MetadataPool + */ + private $metadataPool; + /** * @param ResourceConnection $resource * @param \Magento\CatalogInventory\Model\ResourceModel\Indexer\StockFactory $indexerFactory * @param \Magento\Catalog\Model\Product\Type $catalogProductType * @param \Magento\Framework\Indexer\CacheContext $cacheContext * @param \Magento\Framework\Event\ManagerInterface $eventManager + * @param MetadataPool|null $metadataPool */ public function __construct( ResourceConnection $resource, \Magento\CatalogInventory\Model\ResourceModel\Indexer\StockFactory $indexerFactory, \Magento\Catalog\Model\Product\Type $catalogProductType, \Magento\Framework\Indexer\CacheContext $cacheContext, - \Magento\Framework\Event\ManagerInterface $eventManager + \Magento\Framework\Event\ManagerInterface $eventManager, + MetadataPool $metadataPool = null ) { $this->_resource = $resource; $this->_indexerFactory = $indexerFactory; $this->_catalogProductType = $catalogProductType; $this->cacheContext = $cacheContext; $this->eventManager = $eventManager; + $this->metadataPool = $metadataPool ?: ObjectManager::getInstance()->get(MetadataPool::class); } /** @@ -154,10 +163,15 @@ protected function _getTable($entityName) public function getRelationsByChild($childIds) { $connection = $this->_getConnection(); - $select = $connection->select() - ->from($this->_getTable('catalog_product_relation'), 'parent_id') - ->where('child_id IN(?)', $childIds); - + $linkField = $this->metadataPool->getMetadata(\Magento\Catalog\Api\Data\ProductInterface::class) + ->getLinkField(); + $select = $connection->select()->from( + ['cpe' => $this->_getTable('catalog_product_entity')], + 'entity_id' + )->join( + ['relation' => $this->_getTable('catalog_product_relation')], + 'relation.parent_id = cpe.' . $linkField + )->where('child_id IN(?)', $childIds); return $connection->fetchCol($select); } @@ -230,7 +244,8 @@ protected function _reindexRows($productIds = []) if (!is_array($productIds)) { $productIds = [$productIds]; } - + $parentIds = $this->getRelationsByChild($productIds); + $productIds = $parentIds ? array_unique(array_merge($parentIds, $productIds)) : $productIds; $this->getCacheCleaner()->clean($productIds, function () use ($productIds) { $this->doReindex($productIds); }); @@ -248,13 +263,10 @@ private function doReindex($productIds = []) { $connection = $this->_getConnection(); - $parentIds = $this->getRelationsByChild($productIds); - $processIds = $parentIds ? array_merge($parentIds, $productIds) : $productIds; - // retrieve product types by processIds $select = $connection->select() ->from($this->_getTable('catalog_product_entity'), ['entity_id', 'type_id']) - ->where('entity_id IN(?)', $processIds); + ->where('entity_id IN(?)', $productIds); $pairs = $connection->fetchPairs($select); $byType = []; diff --git a/app/code/Magento/CatalogInventory/Model/Plugin/ReindexUpdatedProducts.php b/app/code/Magento/CatalogInventory/Model/Plugin/ReindexUpdatedProducts.php new file mode 100644 index 0000000000000..f10afcd4ea329 --- /dev/null +++ b/app/code/Magento/CatalogInventory/Model/Plugin/ReindexUpdatedProducts.php @@ -0,0 +1,46 @@ +indexerProcessor = $indexerProcessor; + } + + /** + * Reindex on product attribute mass change + * + * @param ProductAction $subject + * @param ProductAction $action + * @param array $productIds + * @return ProductAction + * + * @SuppressWarnings(PHPMD.UnusedFormalParameter) + */ + public function afterUpdateAttributes( + ProductAction $subject, + ProductAction $action, + $productIds + ) { + $this->indexerProcessor->reindexList(array_unique($productIds)); + return $action; + } +} diff --git a/app/code/Magento/CatalogInventory/etc/di.xml b/app/code/Magento/CatalogInventory/etc/di.xml index 2a55d745e1185..65bc277121429 100644 --- a/app/code/Magento/CatalogInventory/etc/di.xml +++ b/app/code/Magento/CatalogInventory/etc/di.xml @@ -78,6 +78,14 @@ + + + + + + Magento\CatalogInventory\Model\Indexer\Stock\Processor + + diff --git a/app/code/Magento/Search/Model/SynonymAnalyzer.php b/app/code/Magento/Search/Model/SynonymAnalyzer.php index 6d5daf6115c0d..a63c10da169d7 100644 --- a/app/code/Magento/Search/Model/SynonymAnalyzer.php +++ b/app/code/Magento/Search/Model/SynonymAnalyzer.php @@ -3,10 +3,15 @@ * Copyright © Magento, Inc. All rights reserved. * See COPYING.txt for license details. */ + +declare(strict_types=1); namespace Magento\Search\Model; use Magento\Search\Api\SynonymAnalyzerInterface; +/** + * SynonymAnalyzer responsible for search of synonyms matching a word or a phrase. + */ class SynonymAnalyzer implements SynonymAnalyzerInterface { /** @@ -39,58 +44,125 @@ public function __construct(SynonymReader $synReader) * ] * @param string $phrase * @return array + * @throws \Magento\Framework\Exception\LocalizedException */ public function getSynonymsForPhrase($phrase) { - $synGroups = []; + $result = []; - if (empty($phrase)) { - return $synGroups; + if (empty(trim($phrase))) { + return $result; } - $rows = $this->synReaderModel->loadByPhrase($phrase)->getData(); - $synonyms = []; - foreach ($rows as $row) { - $synonyms [] = $row['synonyms']; - } + $synonymGroups = $this->getSynonymGroupsByPhrase($phrase); + + // Replace multiple spaces in a row with the only one space + $phrase = preg_replace("/ {2,}/", " ", $phrase); // Go through every returned record looking for presence of the actual phrase. If there were no matching // records found in DB then create a new entry for it in the returned array $words = explode(' ', $phrase); - foreach ($words as $w) { - $position = $this->findInArray($w, $synonyms); - if ($position !== false) { - $synGroups[] = explode(',', $synonyms[$position]); - } else { - // No synonyms were found. Return the original word in this position - $synGroups[] = [$w]; + + foreach ($words as $offset => $word) { + $synonyms = [$word]; + + if ($synonymGroups) { + $pattern = $this->getSearchPattern(\array_slice($words, $offset)); + $position = $this->findInArray($pattern, $synonymGroups); + if ($position !== null) { + $synonyms = explode(',', $synonymGroups[$position]); + } } + + $result[] = $synonyms; } - return $synGroups; + + return $result; } /** - * Helper method to find the presence of $word in $wordsArray. If found, the particular array index is returned. + * Helper method to find the matching of $pattern to $synonymGroupsToExamine. + * If matches, the particular array index is returned. * Otherwise false will be returned. * - * @param string $word - * @param $array $wordsArray - * @return boolean | int + * @param string $pattern + * @param array $synonymGroupsToExamine + * @return int|null */ - private function findInArray($word, $wordsArray) + private function findInArray(string $pattern, array $synonymGroupsToExamine) { - if (empty($wordsArray)) { - return false; - } $position = 0; - foreach ($wordsArray as $wordsLine) { - $pattern = '/^' . $word . ',|,' . $word . ',|,' . $word . '$/'; - $rv = preg_match($pattern, $wordsLine); - if ($rv != 0) { + foreach ($synonymGroupsToExamine as $synonymGroup) { + $matchingResultCode = preg_match($pattern, $synonymGroup); + if ($matchingResultCode === 1) { return $position; } $position++; } - return false; + return null; + } + + /** + * Returns a regular expression to search for synonyms of the phrase represented as the list of words. + * + * Returned pattern contains expression to search for a part of the phrase from the beginning. + * + * For example, in the phrase "Elizabeth is the English queen" with subset from the very first word, + * the method will build an expression which looking for synonyms for all these patterns: + * - Elizabeth is the English queen + * - Elizabeth is the English + * - Elizabeth is the + * - Elizabeth is + * - Elizabeth + * + * For the same phrase on the second iteration with the first word "is" it will match for these synonyms: + * - is the English queen + * - is the English + * - is the + * - is + * + * The pattern looking for exact match and will not find these phrases as synonyms: + * - Is there anybody in the room? + * - Is the English is most popular language? + * - Is the English queen Elizabeth? + * + * Take into account that returned pattern expects that data will be represented as comma-separated value. + * + * @param array $words + * @return string + */ + private function getSearchPattern(array $words): string + { + $patterns = []; + for ($lastItem = count($words); $lastItem > 0; $lastItem--) { + $phrase = implode("\s+", \array_slice($words, 0, $lastItem)); + $patterns[] = '^' . $phrase . ','; + $patterns[] = ',' . $phrase . ','; + $patterns[] = ',' . $phrase . '$'; + } + + $pattern = '/' . implode('|', $patterns) . '/i'; + return $pattern; + } + + /** + * Get all synonym groups for the phrase + * + * Returns an array of synonyms which are represented as comma-separated value for each item in the list + * + * @param string $phrase + * @return string[] + * @throws \Magento\Framework\Exception\LocalizedException + */ + private function getSynonymGroupsByPhrase(string $phrase): array + { + $result = []; + + /** @var array $synonymGroups */ + $synonymGroups = $this->synReaderModel->loadByPhrase($phrase)->getData(); + foreach ($synonymGroups as $row) { + $result[] = $row['synonyms']; + } + return $result; } } diff --git a/app/code/Magento/Search/Model/SynonymReader.php b/app/code/Magento/Search/Model/SynonymReader.php index 202931665f493..078a3eb178cbe 100644 --- a/app/code/Magento/Search/Model/SynonymReader.php +++ b/app/code/Magento/Search/Model/SynonymReader.php @@ -78,6 +78,7 @@ protected function _construct() * * @param string $phrase * @return $this + * @throws \Magento\Framework\Exception\LocalizedException * @since 100.1.0 */ public function loadByPhrase($phrase) diff --git a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/ActionTest.php b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/ActionTest.php index 90825ab573d09..39905aeae10f5 100644 --- a/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/ActionTest.php +++ b/dev/tests/integration/testsuite/Magento/Catalog/Model/Product/ActionTest.php @@ -84,6 +84,73 @@ public function testUpdateWebsites() } } + /** + * @magentoDataFixture Magento/ConfigurableProduct/_files/product_configurable.php + * @magentoAppArea adminhtml + * @param string $status + * @param string $productsCount + * @dataProvider updateAttributesDataProvider + */ + public function testUpdateAttributes($status, $productsCount) + { + /** @var \Magento\Framework\Indexer\IndexerRegistry $indexerRegistry */ + $indexerRegistry = \Magento\TestFramework\Helper\Bootstrap::getObjectManager() + ->get(\Magento\Framework\Indexer\IndexerRegistry::class); + $indexerRegistry->get(Fulltext::INDEXER_ID)->setScheduled(false); + + /** @var \Magento\Catalog\Api\ProductRepositoryInterface $productRepository */ + $productRepository = $this->objectManager->create(\Magento\Catalog\Api\ProductRepositoryInterface::class); + + /** @var \Magento\Catalog\Model\Product $product */ + $product = $productRepository->get('configurable'); + $productAttributesOptions = $product->getExtensionAttributes()->getConfigurableProductLinks(); + $attrData = ['status' => $status]; + $configurableOptionsId = []; + if (isset($productAttributesOptions)) { + foreach ($productAttributesOptions as $configurableOption) { + $configurableOptionsId[] = $configurableOption; + } + } + $this->action->updateAttributes($configurableOptionsId, $attrData, $product->getStoreId()); + + $categoryFactory = $this->objectManager->create(\Magento\Catalog\Model\CategoryFactory::class); + /** @var \Magento\Catalog\Block\Product\ListProduct $listProduct */ + $listProduct = $this->objectManager->create(\Magento\Catalog\Block\Product\ListProduct::class); + $category = $categoryFactory->create()->load(2); + $layer = $listProduct->getLayer(); + $layer->setCurrentCategory($category); + $productCollection = $layer->getProductCollection(); + $productCollection->joinField( + 'qty', + 'cataloginventory_stock_status', + 'qty', + 'product_id=entity_id', + '{{table}}.stock_id=1', + 'left' + ); + + $this->assertEquals($productsCount, $productCollection->count()); + } + + /** + * DataProvider for testUpdateAttributes + * + * @return array + */ + public function updateAttributesDataProvider() + { + return [ + [ + 'status' => 2, + 'expected_count' => 0 + ], + [ + 'status' => 1, + 'expected_count' => 1 + ], + ]; + } + public static function tearDownAfterClass() { /** @var \Magento\Framework\Indexer\IndexerRegistry $indexerRegistry */ diff --git a/dev/tests/integration/testsuite/Magento/Search/Model/SynonymAnalyzerTest.php b/dev/tests/integration/testsuite/Magento/Search/Model/SynonymAnalyzerTest.php index 892ab57080a98..9fc9b10c89fa4 100644 --- a/dev/tests/integration/testsuite/Magento/Search/Model/SynonymAnalyzerTest.php +++ b/dev/tests/integration/testsuite/Magento/Search/Model/SynonymAnalyzerTest.php @@ -42,10 +42,38 @@ public static function loadGetSynonymsForPhraseDataProvider() 'phrase' => 'universe is enormous', 'expectedResult' => [['universe', 'cosmos'], ['is'], ['big', 'huge', 'large', 'enormous']] ], + 'WithCaseMismatch' => [ + 'phrase' => 'GNU\'s Not Unix', + 'expectedResult' => [['GNU\'s'], ['Not'], ['unix', 'linux'],] + ], + 'WithMultiWordPhrase' => [ + 'phrase' => 'Coastline of Great Britain stretches for 11,073 miles', + 'expectedResult' => [ + ['Coastline'], + ['of'], + ['Great Britain', 'United Kingdom'], + ['Britain'], + ['stretches'], + ['for'], + ['11,073'], + ['miles'] + ] + ], + 'PartialSynonymMatching' => [ + 'phrase' => 'Magento Engineering', + 'expectedResult' => [ + ['orange', 'magento'], + ['Engineering', 'Technical Staff'] + ] + ], 'noSynonyms' => [ 'phrase' => 'this sentence has no synonyms', 'expectedResult' => [['this'], ['sentence'], ['has'], ['no'], ['synonyms']] ], + 'multipleSpaces' => [ + 'phrase' => 'GNU\'s Not Unix', + 'expectedResult' => [['GNU\'s'], ['Not'], ['unix', 'linux'],] + ], 'oneMoreTest' => [ 'phrase' => 'schlicht', 'expectedResult' => [['schlicht', 'natürlich']] diff --git a/dev/tests/integration/testsuite/Magento/Search/_files/synonym_reader.php b/dev/tests/integration/testsuite/Magento/Search/_files/synonym_reader.php index f529b61522967..78c30cf458c51 100644 --- a/dev/tests/integration/testsuite/Magento/Search/_files/synonym_reader.php +++ b/dev/tests/integration/testsuite/Magento/Search/_files/synonym_reader.php @@ -24,9 +24,22 @@ $synonymsModel = $objectManager->create(\Magento\Search\Model\SynonymReader::class); $synonymsModel->setSynonyms('hill,mountain,peak')->setWebsiteId(1)->save(); +$synonymsModel = $objectManager->create(\Magento\Search\Model\SynonymReader::class); +$synonymsModel->setSynonyms('Community Engineering,Contributors,Magento Community Engineering')->setWebsiteId(1) + ->save(); + +$synonymsModel = $objectManager->create(\Magento\Search\Model\SynonymReader::class); +$synonymsModel->setSynonyms('Engineering,Technical Staff')->setWebsiteId(1)->save(); + // Synonym groups for "All Store Views" $synonymsModel = $objectManager->create(\Magento\Search\Model\SynonymReader::class); $synonymsModel->setSynonyms('universe,cosmos')->setWebsiteId(0)->save(); +$synonymsModel = $objectManager->create(\Magento\Search\Model\SynonymReader::class); +$synonymsModel->setSynonyms('unix,linux')->setWebsiteId(0)->save(); + +$synonymsModel = $objectManager->create(\Magento\Search\Model\SynonymReader::class); +$synonymsModel->setSynonyms('Great Britain,United Kingdom')->setWebsiteId(0)->save(); + $synonymsModel = $objectManager->create(\Magento\Search\Model\SynonymReader::class); $synonymsModel->setSynonyms('big,huge,large,enormous')->setWebsiteId(0)->save();