diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product.php b/app/code/Magento/CatalogImportExport/Model/Import/Product.php index edeb955b19c9b..b598d0180826d 100644 --- a/app/code/Magento/CatalogImportExport/Model/Import/Product.php +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product.php @@ -10,6 +10,7 @@ use Magento\Catalog\Model\Config as CatalogConfig; use Magento\Catalog\Model\Product\Visibility; use Magento\CatalogImportExport\Model\Import\Product\ImageTypeProcessor; +use Magento\CatalogImportExport\Model\Import\Product\LinkProcessor; use Magento\CatalogImportExport\Model\Import\Product\MediaGalleryProcessor; use Magento\CatalogImportExport\Model\Import\Product\RowValidatorInterface as ValidatorInterface; use Magento\CatalogImportExport\Model\StockItemImporterInterface; @@ -219,7 +220,7 @@ class Product extends \Magento\ImportExport\Model\Import\Entity\AbstractEntity /** * Links attribute name-to-link type ID. - * + * @deprecated kept for BC, use DI to inject to LinkProcessor * @var array */ protected $_linkNameToId = [ @@ -731,6 +732,12 @@ class Product extends \Magento\ImportExport\Model\Import\Entity\AbstractEntity */ private $mediaProcessor; + /** + * @var LinkProcessor + */ + private $linkProcessor; + + /** * @var DateTimeFactory */ @@ -835,7 +842,8 @@ public function __construct( MediaGalleryProcessor $mediaProcessor = null, StockItemImporterInterface $stockItemImporter = null, DateTimeFactory $dateTimeFactory = null, - ProductRepositoryInterface $productRepository = null + ProductRepositoryInterface $productRepository = null, + LinkProcessor $linkProcessor = null ) { $this->_eventManager = $eventManager; $this->stockRegistry = $stockRegistry; @@ -891,6 +899,9 @@ public function __construct( $this->dateTimeFactory = $dateTimeFactory ?? ObjectManager::getInstance()->get(DateTimeFactory::class); $this->productRepository = $productRepository ?? ObjectManager::getInstance() ->get(ProductRepositoryInterface::class); + $this->linkProcessor = $linkProcessor ?? ObjectManager::getInstance() + ->get(LinkProcessor::class); + $this->linkProcessor->addNameToIds($this->_linkNameToId); } /** @@ -1111,7 +1122,7 @@ protected function _saveProductsData() foreach ($this->_productTypeModels as $productTypeModel) { $productTypeModel->saveData(); } - $this->_saveLinks(); + $this->linkProcessor->saveLinks($this, $this->getProductEntityLinkField()); $this->_saveStockItem(); if ($this->_replaceFlag) { $this->getOptionEntity()->clearProductsSkuToId(); @@ -1244,139 +1255,15 @@ protected function _prepareRowForDb(array $rowData) * Must be called after ALL products saving done. * * @return $this - * @SuppressWarnings(PHPMD.CyclomaticComplexity) - * @SuppressWarnings(PHPMD.NPathComplexity) - * @SuppressWarnings(PHPMD.ExcessiveMethodLength) - * phpcs:disable Generic.Metrics.NestingLevel + * + * @deprecated use linkProcessor */ protected function _saveLinks() { - $resource = $this->_linkFactory->create(); - $mainTable = $resource->getMainTable(); - $positionAttrId = []; - $nextLinkId = $this->_resourceHelper->getNextAutoincrement($mainTable); - - // pre-load 'position' attributes ID for each link type once - foreach ($this->_linkNameToId as $linkId) { - $select = $this->_connection->select()->from( - $resource->getTable('catalog_product_link_attribute'), - ['id' => 'product_link_attribute_id'] - )->where( - 'link_type_id = :link_id AND product_link_attribute_code = :position' - ); - $bind = [':link_id' => $linkId, ':position' => 'position']; - $positionAttrId[$linkId] = $this->_connection->fetchOne($select, $bind); - } - while ($bunch = $this->_dataSourceModel->getNextBunch()) { - $productIds = []; - $linkRows = []; - $positionRows = []; - - foreach ($bunch as $rowNum => $rowData) { - if (!$this->isRowAllowedToImport($rowData, $rowNum)) { - continue; - } - - $sku = $rowData[self::COL_SKU]; - - $productId = $this->skuProcessor->getNewSku($sku)[$this->getProductEntityLinkField()]; - $productLinkKeys = []; - $select = $this->_connection->select()->from( - $resource->getTable('catalog_product_link'), - ['id' => 'link_id', 'linked_id' => 'linked_product_id', 'link_type_id' => 'link_type_id'] - )->where( - 'product_id = :product_id' - ); - $bind = [':product_id' => $productId]; - foreach ($this->_connection->fetchAll($select, $bind) as $linkData) { - $linkKey = "{$productId}-{$linkData['linked_id']}-{$linkData['link_type_id']}"; - $productLinkKeys[$linkKey] = $linkData['id']; - } - foreach ($this->_linkNameToId as $linkName => $linkId) { - $productIds[] = $productId; - if (isset($rowData[$linkName . 'sku'])) { - $linkSkus = explode($this->getMultipleValueSeparator(), $rowData[$linkName . 'sku']); - $linkPositions = !empty($rowData[$linkName . 'position']) - ? explode($this->getMultipleValueSeparator(), $rowData[$linkName . 'position']) - : []; - foreach ($linkSkus as $linkedKey => $linkedSku) { - $linkedSku = trim($linkedSku); - if (($this->skuProcessor->getNewSku($linkedSku) !== null || $this->isSkuExist($linkedSku)) - && strcasecmp($linkedSku, $sku) !== 0 - ) { - $newSku = $this->skuProcessor->getNewSku($linkedSku); - if (!empty($newSku)) { - $linkedId = $newSku['entity_id']; - } else { - $linkedId = $this->getExistingSku($linkedSku)['entity_id']; - } - - if ($linkedId == null) { - // Import file links to a SKU which is skipped for some reason, - // which leads to a "NULL" - // link causing fatal errors. - $this->_logger->critical( - new \Exception( - sprintf( - 'WARNING: Orphaned link skipped: From SKU %s (ID %d) to SKU %s, ' . - 'Link type id: %d', - $sku, - $productId, - $linkedSku, - $linkId - ) - ) - ); - continue; - } - - $linkKey = "{$productId}-{$linkedId}-{$linkId}"; - if (empty($productLinkKeys[$linkKey])) { - $productLinkKeys[$linkKey] = $nextLinkId; - } - if (!isset($linkRows[$linkKey])) { - $linkRows[$linkKey] = [ - 'link_id' => $productLinkKeys[$linkKey], - 'product_id' => $productId, - 'linked_product_id' => $linkedId, - 'link_type_id' => $linkId, - ]; - } - if (!empty($linkPositions[$linkedKey])) { - $positionRows[] = [ - 'link_id' => $productLinkKeys[$linkKey], - 'product_link_attribute_id' => $positionAttrId[$linkId], - 'value' => $linkPositions[$linkedKey], - ]; - } - $nextLinkId++; - } - } - } - } - } - if (Import::BEHAVIOR_APPEND != $this->getBehavior() && $productIds) { - $this->_connection->delete( - $mainTable, - $this->_connection->quoteInto('product_id IN (?)', array_unique($productIds)) - ); - } - if ($linkRows) { - $this->_connection->insertOnDuplicate($mainTable, $linkRows, ['link_id']); - } - if ($positionRows) { - // process linked product positions - $this->_connection->insertOnDuplicate( - $resource->getAttributeTypeTable('int'), - $positionRows, - ['value'] - ); - } - } + $this->linkProcessor->process($this, $this->getProductEntityLinkField()); return $this; } - // phpcs:enable - + /** * Save product attributes. * @@ -3028,7 +2915,7 @@ private function parseMultipleValues($labelRow) * @param string $sku * @return bool */ - private function isSkuExist($sku) + public function isSkuExist($sku) { $sku = strtolower($sku); return isset($this->_oldSku[$sku]); @@ -3040,7 +2927,7 @@ private function isSkuExist($sku) * @param string $sku * @return array */ - private function getExistingSku($sku) + public function getExistingSku($sku) { return $this->_oldSku[strtolower($sku)]; } diff --git a/app/code/Magento/CatalogImportExport/Model/Import/Product/LinkProcessor.php b/app/code/Magento/CatalogImportExport/Model/Import/Product/LinkProcessor.php new file mode 100644 index 0000000000000..83e0030b82028 --- /dev/null +++ b/app/code/Magento/CatalogImportExport/Model/Import/Product/LinkProcessor.php @@ -0,0 +1,258 @@ +linkFactory = $linkFactory; + $this->logger = $logger; + $this->dataSourceModel = $importData; + $this->skuProcessor = $skuProcessor; + $this->linkNameToId = $linkNameToId; + + $this->resource = $this->linkFactory->create(['connectionName' => null]); + } + + /** + * Add additional links mappings + * + * Here for the sole reason of BC in the parent class, use DI instead + * + * @deprecated + */ + public function addNameToIds($nameToIds) + { + $this->linkNameToId = array_merge($nameToIds, $this->linkNameToId); + } + + public function saveLinks($entityModel, $linkField) + { + $this->entityModel = $entityModel; + + $nextLinkId = $this->resource->getNextAutoincrement(); + $positionAttrId = $this->resource->loadPositionAttributes($this->linkNameToId); + + while ($bunch = $this->dataSourceModel->getNextBunch()) { + list($productIds, $linkRows, $positionRows, $nextLinkId) = $this->processBunch( + $bunch, + $linkField, + $positionAttrId, + $nextLinkId + ); + + if (Import::BEHAVIOR_APPEND !== $this->entityModel->getBehavior() && $productIds) { + $this->resource->deleteExistingLinks($productIds); + } + $this->resource->insertNewLinks($linkRows, $positionRows); + } + + return $this; + } + + /** + * Check for empty link id, if it is empty the + * import file links to a SKU which is skipped for some reason, + * which leads to a "NULL" link causing fatal errors. + * + * @param $linkId + * @param $sku + * @param $productId + * @param string $linkedSku + */ + private function checkForEmptyLinkId($linkedId, $sku, $productId, string $linkedSku): bool + { + if ($linkedId == null) { + $this->logger->critical( + new \Exception( + sprintf( + 'WARNING: Orphaned link skipped: From SKU %s (ID %d) to SKU %s, Link type id: %d', + $sku, + $productId, + $linkedSku, + $linkedId + ) + ) + ); + + return true; + } + + return false; + } + + /** + * @param string $linkedSku + * @param $sku + * + * @return bool + */ + private function isProperLink(string $linkedSku, $sku): bool + { + return ($this->skuProcessor->getNewSku($linkedSku) !== null || $this->entityModel->isSkuExist($linkedSku)) + && strcasecmp($linkedSku, $sku) !== 0; + } + + /** + * @param $rowData + * @param string $positionField + * + * @return array + */ + private function getLinkPositions($rowData, string $linkName): array + { + $positionField = $linkName . 'position'; + + return ! empty($rowData[$positionField]) + ? explode($this->entityModel->getMultipleValueSeparator(), $rowData[$positionField]) + : []; + } + + /** + * @param $rowData + * @param string $linkField + * + * @return bool|array + */ + private function getLinkSkus($rowData, string $linkName) + { + $linkField = $linkName . 'sku'; + + if (! isset($rowData[$linkField])) { + return false; + } + + return explode($this->entityModel->getMultipleValueSeparator(), $rowData[$linkField]); + } + + /** + * @param string $linkedSku + * + * @return mixed + */ + private function getLinkedId(string $linkedSku) + { + $newSku = $this->skuProcessor->getNewSku($linkedSku); + if (! empty($newSku)) { + return $newSku['entity_id']; + } + + return $this->entityModel->getExistingSku($linkedSku)['entity_id']; + } + + private function processBunch( + ?array $bunch, + $linkField, + array $positionAttrId, + int $nextLinkId + ): array { + $productIds = []; + $positionRows = []; + $linkRows = []; + + foreach ($bunch as $rowNum => $rowData) { + if (! $this->entityModel->isRowAllowedToImport($rowData, $rowNum)) { + continue; + } + + $sku = $rowData[Product::COL_SKU]; + + $productId = $this->skuProcessor->getNewSku($sku)[$linkField]; + $productLinkKeys = $this->resource->fetchExistingLinks($productId); + + foreach ($this->linkNameToId as $linkName => $linkId) { + $productIds[] = $productId; + + $linkSkus = $this->getLinkSkus($rowData, $linkName); + if ($linkSkus === false) { + continue; + } + + $linkPositions = $this->getLinkPositions($rowData, $linkName); + + foreach ($linkSkus as $linkedKey => $linkedSku) { + $linkedSku = trim($linkedSku); //NOTE: why is trimming happening here and not for all cols in a general place? + if (! $this->isProperLink($linkedSku, $sku)) { + continue; + } + + $linkedId = $this->getLinkedId($linkedSku); + if ($this->checkForEmptyLinkId($linkedId, $sku, $productId, $linkedSku)) { + continue; + } + + $linkKey = "{$productId}-{$linkedId}-{$linkId}"; + if (empty($productLinkKeys[$linkKey])) { + $productLinkKeys[$linkKey] = $nextLinkId; + } + + if (! isset($linkRows[$linkKey])) { + $linkRows[$linkKey] = [ + 'link_id' => $productLinkKeys[$linkKey], + 'product_id' => $productId, + 'linked_product_id' => $linkedId, + 'link_type_id' => $linkId, + ]; + } + if (! empty($linkPositions[$linkedKey])) { + $positionRows[] = [ + 'link_id' => $productLinkKeys[$linkKey], + 'product_link_attribute_id' => $positionAttrId[$linkId], + 'value' => $linkPositions[$linkedKey], + ]; + } + $nextLinkId++; + } + } + } + + return [$productIds, $linkRows, $positionRows, $nextLinkId]; + } +} diff --git a/app/code/Magento/CatalogImportExport/Model/ResourceModel/Product/Link.php b/app/code/Magento/CatalogImportExport/Model/ResourceModel/Product/Link.php new file mode 100644 index 0000000000000..f0abeb627a392 --- /dev/null +++ b/app/code/Magento/CatalogImportExport/Model/ResourceModel/Product/Link.php @@ -0,0 +1,114 @@ +resourceHelper = $resourceHelper; + } + + /** + * @param \Magento\Catalog\Model\ResourceModel\Product\Link $resource + * @param $productId + * + * @return array + */ + public function fetchExistingLinks( + $productId + ): array { + $productLinkKeys = []; + $select = $this->getConnection()->select()->from( + $this->getTable('catalog_product_link'), + ['id' => 'link_id', 'linked_id' => 'linked_product_id', 'link_type_id' => 'link_type_id'] + )->where( + 'product_id = :product_id' + ); + $bind = [':product_id' => $productId]; + foreach ($this->getConnection()->fetchAll($select, $bind) as $linkData) { + $linkKey = "{$productId}-{$linkData['linked_id']}-{$linkData['link_type_id']}"; + $productLinkKeys[$linkKey] = $linkData['id']; + } + + return $productLinkKeys; + } + + /** + * pre-load 'position' attributes ID for each link type once + * + * @param array $linkNameToId + * + * @return array + */ + public function loadPositionAttributes(array $linkNameToId): array + { + $positionAttrId = []; + + foreach ($linkNameToId as $linkId) { + $select = $this->getConnection()->select()->from( + $this->getTable('catalog_product_link_attribute'), + ['id' => 'product_link_attribute_id'] + )->where( + 'link_type_id = :link_id AND product_link_attribute_code = :position' + ); + $bind = [':link_id' => $linkId, ':position' => 'position']; + $positionAttrId[$linkId] = $this->getConnection()->fetchOne($select, $bind); + } + + return $positionAttrId; + } + + /** + * @param array $productIds + */ + public function deleteExistingLinks(array $productIds): void + { + $this->getConnection()->delete( + $this->getMainTable(), + $this->getConnection()->quoteInto('product_id IN (?)', array_unique($productIds)) + ); + } + + /** + * @param array $linkRows + * @param array $positionRows + */ + public function insertNewLinks(array $linkRows, array $positionRows): void + { + if ($linkRows) { + $this->getConnection()->insertOnDuplicate($this->getMainTable(), $linkRows, ['link_id']); + } + if ($positionRows) { + // process linked product positions + $this->getConnection()->insertOnDuplicate( + $this->getAttributeTypeTable('int'), + $positionRows, + ['value'] + ); + } + } + + /** + * @return int + * @throws \Magento\Framework\Exception\LocalizedException + */ + public function getNextAutoincrement(): int + { + $mainTable = $this->getMainTable(); + $nextLinkId = $this->resourceHelper->getNextAutoincrement($mainTable); + + return $nextLinkId; + } +} diff --git a/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/ProductTest.php b/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/ProductTest.php index f85d33edb5d8c..863b22f05b182 100644 --- a/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/ProductTest.php +++ b/app/code/Magento/CatalogImportExport/Test/Unit/Model/Import/ProductTest.php @@ -8,6 +8,7 @@ use Magento\CatalogImportExport\Model\Import\Product; use Magento\CatalogImportExport\Model\Import\Product\ImageTypeProcessor; +use Magento\CatalogImportExport\Model\Import\Product\LinkProcessor; use Magento\Framework\App\Filesystem\DirectoryList; use Magento\ImportExport\Model\Import; use PHPUnit\Framework\MockObject\MockObject; @@ -165,6 +166,9 @@ class ProductTest extends \Magento\ImportExport\Test\Unit\Model\Import\AbstractI /** @var ImageTypeProcessor|MockObject */ protected $imageTypeProcessor; + /** @var LinkProcessor|MockObject */ + protected $linkProcessor; + /** * @SuppressWarnings(PHPMD.ExcessiveMethodLength) */ @@ -336,6 +340,10 @@ protected function setUp() ->disableOriginalConstructor() ->getMock(); + $this->linkProcessor = $this->getMockBuilder(LinkProcessor::class) + ->disableOriginalConstructor() + ->getMock(); + $this->_objectConstructor() ->_parentObjectConstructor() ->_initAttributeSets() @@ -385,7 +393,8 @@ protected function setUp() 'scopeConfig' => $this->scopeConfig, 'productUrl' => $this->productUrl, 'data' => $this->data, - 'imageTypeProcessor' => $this->imageTypeProcessor + 'imageTypeProcessor' => $this->imageTypeProcessor, + 'linkProcessor', $this->linkProcessor ] ); $reflection = new \ReflectionClass(Product::class); diff --git a/app/code/Magento/CatalogImportExport/etc/di.xml b/app/code/Magento/CatalogImportExport/etc/di.xml index 6906272b11d68..64b6e7bcb2afa 100644 --- a/app/code/Magento/CatalogImportExport/etc/di.xml +++ b/app/code/Magento/CatalogImportExport/etc/di.xml @@ -28,4 +28,13 @@ + + + + Magento\Catalog\Model\Product\Link::LINK_TYPE_RELATED + Magento\Catalog\Model\Product\Link::LINK_TYPE_CROSSSELL + Magento\Catalog\Model\Product\Link::LINK_TYPE_UPSELL + + +