From 84c3e850317c0106802e384e8ab679475ba265fc Mon Sep 17 00:00:00 2001 From: Rutger Rademaker Date: Fri, 23 Feb 2024 12:13:04 +0100 Subject: [PATCH 1/9] Add magento repository so that requirements can be installed from the module. --- composer.json | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/composer.json b/composer.json index 5993e66..0896a76 100755 --- a/composer.json +++ b/composer.json @@ -14,5 +14,16 @@ "psr-4": { "Hackathon\\EAVCleaner\\": "" } + }, + "repositories": { + "magento": { + "type": "composer", + "url": "https://repo.magento.com/" + } + }, + "config": { + "allow-plugins": { + "magento/magento-composer-installer": false + } } } From dadf8bc93e8e147c751058aa2bbfd51a1ed44a35 Mon Sep 17 00:00:00 2001 From: Rutger Rademaker Date: Fri, 23 Feb 2024 16:16:50 +0100 Subject: [PATCH 2/9] Introduce optional filter capabilities when restoring default values --- Model/AttributeFilter.php | 8 ++++++++ Model/StoreFilter.php | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 Model/AttributeFilter.php create mode 100644 Model/StoreFilter.php diff --git a/Model/AttributeFilter.php b/Model/AttributeFilter.php new file mode 100644 index 0000000..c4ecda5 --- /dev/null +++ b/Model/AttributeFilter.php @@ -0,0 +1,8 @@ +writeln('Admin values can not be removed!'); + return Command::INVALID; + } + + try { + $storeId = $this->storeRepository->get($storeCode)->getId(); + } catch (\Exception $e) { + $output->writeln('' . $e->getMessage() . ' : ' . $storeCode . ''); + return Command::INVALID; + } + $storeIds[] = $storeId; + } + + $storeIdFilter = sprintf('AND store_id in(%s)', implode($storeIds)); + } + } +} From fc4417d7137e860b97ce759b9af8c35f960f59f3 Mon Sep 17 00:00:00 2001 From: Rutger Rademaker Date: Fri, 23 Feb 2024 16:19:11 +0100 Subject: [PATCH 3/9] Introduce optional filter capabilities when restoring default values --- .gitignore | 4 +- CHANGELOG.md | 6 ++ .../Command/RestoreUseDefaultValueCommand.php | 93 ++++++++++++++++--- Model/AttributeFilter.php | 74 +++++++++++++++ Model/StoreFilter.php | 25 +++-- README.md | 16 ++++ composer.json | 6 +- 7 files changed, 202 insertions(+), 22 deletions(-) diff --git a/.gitignore b/.gitignore index 22ac069..9812cb0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .php_cs.cache -*~ \ No newline at end of file +vendor +composer.lock +*~ diff --git a/CHANGELOG.md b/CHANGELOG.md index abd2328..04b31af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 1.3.0 - 2024-02-23 +### Added +- Option to remove scoped attribute values +### Fixed +- Adobe Commerce B2B is now also detected as Enterprise + ## 1.2.1 - 2021-10-28 ### Added - Add license diff --git a/Console/Command/RestoreUseDefaultValueCommand.php b/Console/Command/RestoreUseDefaultValueCommand.php index 5666ea5..ff2b088 100755 --- a/Console/Command/RestoreUseDefaultValueCommand.php +++ b/Console/Command/RestoreUseDefaultValueCommand.php @@ -2,10 +2,13 @@ namespace Hackathon\EAVCleaner\Console\Command; +use Hackathon\EAVCleaner\Model\AttributeFilter; +use Hackathon\EAVCleaner\Model\StoreFilter; use Magento\Framework\App\ProductMetadataInterface; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Model\ResourceModel\IteratorFactory; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; @@ -26,10 +29,21 @@ class RestoreUseDefaultValueCommand extends Command */ private $resourceConnection; + /** + * @var string + */ + private $storeFilter; + /** + * @var AttributeFilter + */ + private $attributeFilter; + public function __construct( IteratorFactory $iteratorFactory, ProductMetaDataInterface $productMetaData, ResourceConnection $resourceConnection, + StoreFilter $storeFilter, + AttributeFilter $attributeFilter, string $name = null ) { parent::__construct($name); @@ -37,6 +51,8 @@ public function __construct( $this->iteratorFactory = $iteratorFactory; $this->productMetaData = $productMetaData; $this->resourceConnection = $resourceConnection; + $this->storeFilter = $storeFilter; + $this->attributeFilter = $attributeFilter; } protected function configure() @@ -53,7 +69,26 @@ protected function configure() InputOption::VALUE_OPTIONAL, 'Set entity to cleanup (product or category)', 'product' - ); + ) + ->addOption( + 'store_codes', + null, + InputArgument::IS_ARRAY, + "Store codes from which attribute values should be removed (csv)", + ) + ->addOption( + 'exclude_attributes', + null, + InputArgument::IS_ARRAY, + "Attribute codes from which values should be preserved (csv)", + ) + ->addOption( + 'include_attributes', + null, + InputArgument::IS_ARRAY, + "Attribute codes from which values should be removed (csv)", + ) + ->addOption('always_remove'); } public function execute(InputInterface $input, OutputInterface $output): int @@ -61,18 +96,34 @@ public function execute(InputInterface $input, OutputInterface $output): int $isDryRun = $input->getOption('dry-run'); $isForce = $input->getOption('force'); $entity = $input->getOption('entity'); + $storeCodes = $input->getOption('store_codes'); + $excludeAttributes = $input->getOption('exclude_attributes'); + $includeAttributes = $input->getOption('include_attributes'); + $isAlwaysRemove = $input->getOption('always_remove'); + + $storeIdFilter=$this->storeFilter->getStoreFilter($output, $storeCodes); + + if (NULL === $storeIdFilter) { + return Command::FAILURE; + } if (!in_array($entity, ['product', 'category'])) { $output->writeln('Please specify the entity with --entity. Possible options are product or category'); - return 1; // error. + return Command::FAILURE; + } + + $attributeFilter=$this->attributeFilter->getAttributeFilterIds($output, $entity, $excludeAttributes, $includeAttributes); + + if (NULL === $attributeFilter) { + return Command::FAILURE; } if (!$isDryRun && !$isForce) { if (!$input->isInteractive()) { $output->writeln('ERROR: neither --dry-run nor --force options were supplied, and we are not running interactively.'); - return 1; // error. + return Command::FAILURE; } $output->writeln('WARNING: this is not a dry run. If you want to do a dry-run, add --dry-run.'); @@ -87,25 +138,40 @@ public function execute(InputInterface $input, OutputInterface $output): int $dbWrite = $this->resourceConnection->getConnection('core_write'); $counts = []; $tables = ['varchar', 'int', 'decimal', 'text', 'datetime']; - $column = $this->productMetaData->getEdition() === 'Enterprise' ? 'row_id' : 'entity_id'; + $column = $this->productMetaData->getEdition() === 'Community' ? 'entity_id' : 'row_id'; foreach ($tables as $table) { // Select all non-global values $fullTableName = $this->resourceConnection->getTableName('catalog_' . $entity . '_entity_' . $table); // NULL values are handled separately - $query = $dbRead->query("SELECT * FROM $fullTableName WHERE store_id != 0 AND value IS NOT NULL"); + $rawQuery=sprintf( + "SELECT * FROM $fullTableName WHERE store_id != 0 %s %s AND value IS NOT NULL", + $storeIdFilter, + $attributeFilter + ); + $output->writeln(sprintf('%s', $rawQuery)); + $query = $dbRead->query($rawQuery); $iterator = $this->iteratorFactory->create(); - $iterator->walk($query, [function (array $result) use ($column, &$counts, $dbRead, $dbWrite, $fullTableName, $isDryRun, $output): void { + $iterator->walk($query, [function (array $result) use ($column, &$counts, $dbRead, $dbWrite, $fullTableName, $isDryRun, $output, $isAlwaysRemove): void { $row = $result['row']; - // Select the global value if it's the same as the non-global value - $query = $dbRead->query( - 'SELECT * FROM ' . $fullTableName - . ' WHERE attribute_id = ? AND store_id = ? AND ' . $column . ' = ? AND BINARY value = ?', - [$row['attribute_id'], 0, $row[$column], $row['value']] - ); + if (!$isAlwaysRemove) { + // Select the global value if it's the same as the non-global value + $query = $dbRead->query( + 'SELECT * FROM ' . $fullTableName + . ' WHERE attribute_id = ? AND store_id = ? AND ' . $column . ' = ? AND BINARY value = ?', + [$row['attribute_id'], 0, $row[$column], $row['value']] + ); + } else { + // Select all value, also if it is not the same as the non-global value + $query = $dbRead->query( + 'SELECT * FROM ' . $fullTableName + . ' WHERE attribute_id = ? AND store_id = ? AND ' . $column . ' = ?', + [$row['attribute_id'], 0, $row[$column]] + ); + } $iterator = $this->iteratorFactory->create(); $iterator->walk($query, [function (array $result) use (&$counts, $dbWrite, $fullTableName, $isDryRun, $output, $row): void { @@ -121,8 +187,9 @@ public function execute(InputInterface $input, OutputInterface $output): int $output->writeln( 'Deleting value ' . $row['value_id'] . ' "' . $row['value'] . '" in favor of ' - . $result['value_id'] + . $result['value_id'] . ' "' . $result ['value'] . '"' . ' for attribute ' . $row['attribute_id'] . ' in table ' . $fullTableName + . ' for store_id ' . $row ['store_id'] ); if (!isset($counts[$row['attribute_id']])) { diff --git a/Model/AttributeFilter.php b/Model/AttributeFilter.php index c4ecda5..a944ebc 100644 --- a/Model/AttributeFilter.php +++ b/Model/AttributeFilter.php @@ -2,7 +2,81 @@ namespace Hackathon\EAVCleaner\Model; +use Magento\Eav\Model\ResourceModel\Entity\Attribute; +use Symfony\Component\Console\Output\OutputInterface; + class AttributeFilter { + /** + * @var EavSetupFactory + */ + private $attribute; + + /** + * @param Attribute $attribute + */ + public function __construct( + Attribute $attribute + ) { + $this->attribute = $attribute; + } + + /** + * @param OutputInterface $output + * @param string $entityType + * @param string|null $excludeAttributes + * @param string|null $includeAttributes + * + * @return array|null + */ + public function getAttributeFilterIds( + OutputInterface $output, + string $entityType, + ?string $excludeAttributes, + ?string $includeAttributes + ) : ?string + { + if ($excludeAttributes === NULL && $includeAttributes === NULL) { + return NULL; + } + + $attributeFilter=""; + + if ($includeAttributes !== NULL) { + $includedIds = $this->getAttributeIds($output, $entityType, $includeAttributes); + if (empty($includedIds)) { + return null; + } else { + $attributeFilter .= sprintf('AND attribute_id IN(%s)', implode(",",$includedIds)); + } + } + + if ($excludeAttributes !== NULL) { + $excludedIds = $this->getAttributeIds($output, $entityType, $excludeAttributes); + if (empty($excludedIds)) { + return null; + } else { + $attributeFilter .= sprintf('AND attribute_id NOT IN(%s)', implode(",",$excludedIds)); + } + } + + return $attributeFilter; + } + + private function getAttributeIds($output, $entityType, $attributeCodes): ?array + { + $attributes = explode(',', $attributeCodes); + $attributeIds=[]; + foreach ($attributes as $attributeCode) { + $attributeId=$this->attribute->getIdByCode("catalog_".$entityType, $attributeCode); + if($attributeId === false) { + $output->writeln(sprintf('Attribute with code `%s` does not exist', $attributeCode)); + return null; + } else { + $attributeIds[]=$attributeId; + } + } + return $attributeIds; + } } diff --git a/Model/StoreFilter.php b/Model/StoreFilter.php index 02e0dab..8f2a64e 100644 --- a/Model/StoreFilter.php +++ b/Model/StoreFilter.php @@ -2,13 +2,24 @@ namespace Hackathon\EAVCleaner\Model; +use Magento\Store\Api\StoreRepositoryInterface; use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Output\OutputInterface; -class StoreCodeFilter +class StoreFilter { - public function getStoreFilter($storeCodes) : ?array + /** + * @var StoreRepositoryInterface + */ + private $storeRepository; + + public function __construct(StoreRepositoryInterface $storeRepository) + { + $this->storeRepository = $storeRepository; + } + + public function getStoreFilter(OutputInterface $output, ?string $storeCodes) : ?string { - $storeIdFilter = ""; if ($storeCodes !== NULL) { $storeCodesArray = explode(',', $storeCodes); @@ -16,19 +27,21 @@ public function getStoreFilter($storeCodes) : ?array foreach ($storeCodesArray as $storeCode) { if ($storeCode == 'admin') { $output->writeln('Admin values can not be removed!'); - return Command::INVALID; + return NULL; } try { $storeId = $this->storeRepository->get($storeCode)->getId(); } catch (\Exception $e) { $output->writeln('' . $e->getMessage() . ' : ' . $storeCode . ''); - return Command::INVALID; + return NULL; } $storeIds[] = $storeId; } - $storeIdFilter = sprintf('AND store_id in(%s)', implode($storeIds)); + return sprintf('AND store_id in(%s)', implode($storeIds)); + } else { + return ""; } } } diff --git a/README.md b/README.md index d6208c9..cf4ad68 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ Run `bin/magento` in the Magento 2 root and look for the `eav:` commands. * `eav:attributes:remove-unused` Remove attributes with no values set in products and attributes that are not present in any attribute sets. * `eav:media:remove-unused` Remove unused product images. * `eav:clean:attributes-and-values-without-parent` Remove orphaned attribute values - those which are missing a parent entry (with the corresponding `backend_type`) in `eav_attribute`. +* `eav:attributes:remove-scoped-attribute-value` "Restore product's 'Use Default Value' for given stores. ## Dry run Use `--dry-run` to check result without modifying data. @@ -20,6 +21,20 @@ Use `--dry-run` to check result without modifying data. ## Force Use `--force` to skip the confirmation prompt before modifying data. +## Additional options for `eav:attributes:restore-use-default-value` + +### Always remove (restore-use-default-value only) +Use `--always_remove` to remove all values, even if the scoped value is not equal to the base value. + +### Store codes +Use `--store_codes=your_store_code` to only remove values for this store. + +### Include attributes +Use `--include_attributes=some_attribute,some_other_attribute` to only delete values for these attributes. + +### Exclude attributes +Use `--exclude_attributes=some_attribute,some_other_attribute` to preserve values for these attributes. + ## Installation Installation with composer: @@ -31,6 +46,7 @@ composer require magento-hackathon/module-eavcleaner-m2 - Nikita Zhavoronkova - Anastasiia Sukhorukova - Peter Jaap Blaakmeer +- Rutger Rademaker ### Special thanks to - Benno Lippert diff --git a/composer.json b/composer.json index 0896a76..422dcc1 100755 --- a/composer.json +++ b/composer.json @@ -3,7 +3,8 @@ "description": "Purpose of this project is to check for different flaws that can occur due to EAV and provide cleanup functions.", "require": { "php": "~7.3||~8.0", - "magento/magento2-base": "~2.3" + "magento/magento2-base": "~2.3", + "magento/module-eav": "^102.1" }, "license": "MIT", "type": "magento2-module", @@ -23,7 +24,8 @@ }, "config": { "allow-plugins": { - "magento/magento-composer-installer": false + "magento/magento-composer-installer": false, + "magento/composer-dependency-version-audit-plugin": false } } } From 60c0e3fbd479f22db3cea770c8a42fac6aa6719a Mon Sep 17 00:00:00 2001 From: Rutger Rademaker Date: Mon, 26 Feb 2024 10:07:23 +0100 Subject: [PATCH 4/9] Output for only shows the table name once. Rename remove to restore to be consistent with task name Introduce exceptions instead trying to solve this with a null value --- .gitignore | 1 + CHANGELOG.md | 4 +- .../Command/RestoreUseDefaultValueCommand.php | 55 +++++++++++-------- {Model => Filter}/AttributeFilter.php | 17 +++--- .../AdminValuesCanNotBeRemovedException.php | 10 ++++ .../AttributeDoesNotExistException.php | 10 ++++ .../Exception/StoreDoesNotExistException.php | 10 ++++ {Model => Filter}/StoreFilter.php | 25 ++++++--- README.md | 5 +- 9 files changed, 93 insertions(+), 44 deletions(-) rename {Model => Filter}/AttributeFilter.php (75%) create mode 100644 Filter/Exception/AdminValuesCanNotBeRemovedException.php create mode 100644 Filter/Exception/AttributeDoesNotExistException.php create mode 100644 Filter/Exception/StoreDoesNotExistException.php rename {Model => Filter}/StoreFilter.php (53%) diff --git a/.gitignore b/.gitignore index 9812cb0..2e01698 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +.idea .php_cs.cache vendor composer.lock diff --git a/CHANGELOG.md b/CHANGELOG.md index 04b31af..817f3bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,8 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## 1.3.0 - 2024-02-23 +### Changed +- Output for `eav:attributes:restore-use-default-value` only shows the table name once. ### Added -- Option to remove scoped attribute values +- Option to remove scoped attribute values for `eav:attributes:restore-use-default-value` ### Fixed - Adobe Commerce B2B is now also detected as Enterprise diff --git a/Console/Command/RestoreUseDefaultValueCommand.php b/Console/Command/RestoreUseDefaultValueCommand.php index ff2b088..94f49e1 100755 --- a/Console/Command/RestoreUseDefaultValueCommand.php +++ b/Console/Command/RestoreUseDefaultValueCommand.php @@ -2,8 +2,8 @@ namespace Hackathon\EAVCleaner\Console\Command; -use Hackathon\EAVCleaner\Model\AttributeFilter; -use Hackathon\EAVCleaner\Model\StoreFilter; +use Hackathon\EAVCleaner\Filter\AttributeFilter; +use Hackathon\EAVCleaner\Filter\StoreFilter; use Magento\Framework\App\ProductMetadataInterface; use Magento\Framework\App\ResourceConnection; use Magento\Framework\Model\ResourceModel\IteratorFactory; @@ -88,7 +88,7 @@ protected function configure() InputArgument::IS_ARRAY, "Attribute codes from which values should be removed (csv)", ) - ->addOption('always_remove'); + ->addOption('always_restore'); } public function execute(InputInterface $input, OutputInterface $output): int @@ -99,11 +99,12 @@ public function execute(InputInterface $input, OutputInterface $output): int $storeCodes = $input->getOption('store_codes'); $excludeAttributes = $input->getOption('exclude_attributes'); $includeAttributes = $input->getOption('include_attributes'); - $isAlwaysRemove = $input->getOption('always_remove'); + $isAlwaysRestore = $input->getOption('always_restore'); - $storeIdFilter=$this->storeFilter->getStoreFilter($output, $storeCodes); - - if (NULL === $storeIdFilter) { + try { + $storeIdFilter=$this->storeFilter->getStoreFilter($storeCodes); + } catch (Exception $e) { + $output->writeln($e->getMessage()); return Command::FAILURE; } @@ -113,9 +114,10 @@ public function execute(InputInterface $input, OutputInterface $output): int return Command::FAILURE; } - $attributeFilter=$this->attributeFilter->getAttributeFilterIds($output, $entity, $excludeAttributes, $includeAttributes); - - if (NULL === $attributeFilter) { + try { + $attributeFilter=$this->attributeFilter->getAttributeFilterIds($entity, $excludeAttributes, $includeAttributes); + } catch (Exception $e) { + $output->writeln($e->getMessage()); return Command::FAILURE; } @@ -143,21 +145,22 @@ public function execute(InputInterface $input, OutputInterface $output): int foreach ($tables as $table) { // Select all non-global values $fullTableName = $this->resourceConnection->getTableName('catalog_' . $entity . '_entity_' . $table); + $output->writeln(sprintf('Now processing entity `%s` in table `%s`', $entity, $fullTableName )); // NULL values are handled separately - $rawQuery=sprintf( + $nullValuesQuery=sprintf( "SELECT * FROM $fullTableName WHERE store_id != 0 %s %s AND value IS NOT NULL", $storeIdFilter, $attributeFilter ); - $output->writeln(sprintf('%s', $rawQuery)); - $query = $dbRead->query($rawQuery); + $output->writeln(sprintf('%s', $nullValuesQuery)); + $query = $dbRead->query($nullValuesQuery); $iterator = $this->iteratorFactory->create(); - $iterator->walk($query, [function (array $result) use ($column, &$counts, $dbRead, $dbWrite, $fullTableName, $isDryRun, $output, $isAlwaysRemove): void { + $iterator->walk($query, [function (array $result) use ($column, &$counts, $dbRead, $dbWrite, $fullTableName, $isDryRun, $output, $isAlwaysRestore): void { $row = $result['row']; - if (!$isAlwaysRemove) { + if (!$isAlwaysRestore) { // Select the global value if it's the same as the non-global value $query = $dbRead->query( 'SELECT * FROM ' . $fullTableName @@ -165,7 +168,7 @@ public function execute(InputInterface $input, OutputInterface $output): int [$row['attribute_id'], 0, $row[$column], $row['value']] ); } else { - // Select all value, also if it is not the same as the non-global value + // Select all global values. $query = $dbRead->query( 'SELECT * FROM ' . $fullTableName . ' WHERE attribute_id = ? AND store_id = ? AND ' . $column . ' = ?', @@ -186,10 +189,15 @@ public function execute(InputInterface $input, OutputInterface $output): int } $output->writeln( - 'Deleting value ' . $row['value_id'] . ' "' . $row['value'] . '" in favor of ' - . $result['value_id'] . ' "' . $result ['value'] . '"' - . ' for attribute ' . $row['attribute_id'] . ' in table ' . $fullTableName - . ' for store_id ' . $row ['store_id'] + sprintf( + 'Deleting value %s (%s) in favor of %s (%s) for attribute %s for store_id %s', + $row['value_id'], + $row['value'], + $result['value_id'] , + $result ['value'], + $row['attribute_id'], + $row ['store_id'] + ) ); if (!isset($counts[$row['attribute_id']])) { @@ -207,9 +215,12 @@ public function execute(InputInterface $input, OutputInterface $output): int if (!$isDryRun && $nullCount > 0) { $output->writeln("Deleting $nullCount NULL value(s) from $fullTableName"); // Remove all non-global null values - $dbWrite->query( - 'DELETE FROM ' . $fullTableName . ' WHERE store_id != 0 AND value IS NULL' + $removeNullValuesQuery = sprintf('DELETE FROM ' . $fullTableName . ' WHERE store_id != 0 %s %s AND value IS NULL', + $storeIdFilter, + $attributeFilter ); + $output->writeln(sprintf('%s', $removeNullValuesQuery)); + $dbWrite->query($removeNullValuesQuery); } if (count($counts)) { diff --git a/Model/AttributeFilter.php b/Filter/AttributeFilter.php similarity index 75% rename from Model/AttributeFilter.php rename to Filter/AttributeFilter.php index a944ebc..b0b232d 100644 --- a/Model/AttributeFilter.php +++ b/Filter/AttributeFilter.php @@ -1,9 +1,10 @@ getAttributeIds($output, $entityType, $includeAttributes); + $includedIds = $this->getAttributeIds($entityType, $includeAttributes); if (empty($includedIds)) { return null; } else { @@ -52,7 +51,7 @@ public function getAttributeFilterIds( } if ($excludeAttributes !== NULL) { - $excludedIds = $this->getAttributeIds($output, $entityType, $excludeAttributes); + $excludedIds = $this->getAttributeIds($entityType, $excludeAttributes); if (empty($excludedIds)) { return null; } else { @@ -63,15 +62,15 @@ public function getAttributeFilterIds( return $attributeFilter; } - private function getAttributeIds($output, $entityType, $attributeCodes): ?array + private function getAttributeIds(string $entityType, string $attributeCodes): ?array { $attributes = explode(',', $attributeCodes); $attributeIds=[]; foreach ($attributes as $attributeCode) { $attributeId=$this->attribute->getIdByCode("catalog_".$entityType, $attributeCode); if($attributeId === false) { - $output->writeln(sprintf('Attribute with code `%s` does not exist', $attributeCode)); - return null; + $error = sprintf('Attribute with code `%s` does not exis', $attributeCode); + throw new AttributeDoesNotExistException($error); } else { $attributeIds[]=$attributeId; } diff --git a/Filter/Exception/AdminValuesCanNotBeRemovedException.php b/Filter/Exception/AdminValuesCanNotBeRemovedException.php new file mode 100644 index 0000000..d82ab1b --- /dev/null +++ b/Filter/Exception/AdminValuesCanNotBeRemovedException.php @@ -0,0 +1,10 @@ +storeRepository = $storeRepository; } - public function getStoreFilter(OutputInterface $output, ?string $storeCodes) : ?string + /** + * @param string|null $storeCodes + * + * @return string + */ + public function getStoreFilter(?string $storeCodes) : string { if ($storeCodes !== NULL) { $storeCodesArray = explode(',', $storeCodes); @@ -26,16 +32,17 @@ public function getStoreFilter(OutputInterface $output, ?string $storeCodes) : ? $storeIds=[]; foreach ($storeCodesArray as $storeCode) { if ($storeCode == 'admin') { - $output->writeln('Admin values can not be removed!'); - return NULL; + $error = 'Admin values can not be removed!'; + throw new AdminValuesCanNotBeRemovedException($error); } try { $storeId = $this->storeRepository->get($storeCode)->getId(); - } catch (\Exception $e) { - $output->writeln('' . $e->getMessage() . ' : ' . $storeCode . ''); - return NULL; + } catch (NoSuchEntityException $e) { + $error = $e->getMessage() . ' | store ID: ' . $storeCode; + throw new StoreDoesNotExistException($error); } + $storeIds[] = $storeId; } diff --git a/README.md b/README.md index cf4ad68..3ffdac2 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,6 @@ Run `bin/magento` in the Magento 2 root and look for the `eav:` commands. * `eav:attributes:remove-unused` Remove attributes with no values set in products and attributes that are not present in any attribute sets. * `eav:media:remove-unused` Remove unused product images. * `eav:clean:attributes-and-values-without-parent` Remove orphaned attribute values - those which are missing a parent entry (with the corresponding `backend_type`) in `eav_attribute`. -* `eav:attributes:remove-scoped-attribute-value` "Restore product's 'Use Default Value' for given stores. ## Dry run Use `--dry-run` to check result without modifying data. @@ -23,8 +22,8 @@ Use `--force` to skip the confirmation prompt before modifying data. ## Additional options for `eav:attributes:restore-use-default-value` -### Always remove (restore-use-default-value only) -Use `--always_remove` to remove all values, even if the scoped value is not equal to the base value. +### Always remove +Use `--always_restore` to remove all values, even if the scoped value is not equal to the base value. ### Store codes Use `--store_codes=your_store_code` to only remove values for this store. From b87cd8544c29b2ba9d1b1b106b5d4ca94e54a1dc Mon Sep 17 00:00:00 2001 From: Rutger Rademaker Date: Mon, 26 Feb 2024 10:41:33 +0100 Subject: [PATCH 5/9] Improve code readability --- Console/Command/RestoreUseDefaultValueCommand.php | 8 ++++---- Filter/AttributeFilter.php | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Console/Command/RestoreUseDefaultValueCommand.php b/Console/Command/RestoreUseDefaultValueCommand.php index 94f49e1..7f6a88e 100755 --- a/Console/Command/RestoreUseDefaultValueCommand.php +++ b/Console/Command/RestoreUseDefaultValueCommand.php @@ -148,13 +148,13 @@ public function execute(InputInterface $input, OutputInterface $output): int $output->writeln(sprintf('Now processing entity `%s` in table `%s`', $entity, $fullTableName )); // NULL values are handled separately - $nullValuesQuery=sprintf( + $notNullValuesQuery=sprintf( "SELECT * FROM $fullTableName WHERE store_id != 0 %s %s AND value IS NOT NULL", $storeIdFilter, $attributeFilter ); - $output->writeln(sprintf('%s', $nullValuesQuery)); - $query = $dbRead->query($nullValuesQuery); + $output->writeln(sprintf('%s', $notNullValuesQuery)); + $query = $dbRead->query($notNullValuesQuery); $iterator = $this->iteratorFactory->create(); $iterator->walk($query, [function (array $result) use ($column, &$counts, $dbRead, $dbWrite, $fullTableName, $isDryRun, $output, $isAlwaysRestore): void { @@ -190,7 +190,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $output->writeln( sprintf( - 'Deleting value %s (%s) in favor of %s (%s) for attribute %s for store_id %s', + 'Deleting value %s (%s) in favor of %s (%s) for attribute %s for store id %s', $row['value_id'], $row['value'], $result['value_id'] , diff --git a/Filter/AttributeFilter.php b/Filter/AttributeFilter.php index b0b232d..8755d5f 100644 --- a/Filter/AttributeFilter.php +++ b/Filter/AttributeFilter.php @@ -69,7 +69,7 @@ private function getAttributeIds(string $entityType, string $attributeCodes): ?a foreach ($attributes as $attributeCode) { $attributeId=$this->attribute->getIdByCode("catalog_".$entityType, $attributeCode); if($attributeId === false) { - $error = sprintf('Attribute with code `%s` does not exis', $attributeCode); + $error = sprintf('Attribute with code `%s` does not exist', $attributeCode); throw new AttributeDoesNotExistException($error); } else { $attributeIds[]=$attributeId; From e8a4fc50f2ba443ae5b4b9e16233c35e85c8ab18 Mon Sep 17 00:00:00 2001 From: Rutger Rademaker Date: Mon, 26 Feb 2024 19:10:35 +0100 Subject: [PATCH 6/9] be more strict on return types + fix issue where storeids were not used correctly (when multiple stores are used) --- .../Command/RestoreUseDefaultValueCommand.php | 2 +- Filter/AttributeFilter.php | 16 ++++------------ Filter/StoreFilter.php | 2 +- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/Console/Command/RestoreUseDefaultValueCommand.php b/Console/Command/RestoreUseDefaultValueCommand.php index 7f6a88e..1a9d547 100755 --- a/Console/Command/RestoreUseDefaultValueCommand.php +++ b/Console/Command/RestoreUseDefaultValueCommand.php @@ -115,7 +115,7 @@ public function execute(InputInterface $input, OutputInterface $output): int } try { - $attributeFilter=$this->attributeFilter->getAttributeFilterIds($entity, $excludeAttributes, $includeAttributes); + $attributeFilter=$this->attributeFilter->getAttributeFilter($entity, $excludeAttributes, $includeAttributes); } catch (Exception $e) { $output->writeln($e->getMessage()); return Command::FAILURE; diff --git a/Filter/AttributeFilter.php b/Filter/AttributeFilter.php index 8755d5f..ea246fc 100644 --- a/Filter/AttributeFilter.php +++ b/Filter/AttributeFilter.php @@ -29,32 +29,24 @@ public function __construct( * * @return array|null */ - public function getAttributeFilterIds( + public function getAttributeFilter( string $entityType, ?string $excludeAttributes, ?string $includeAttributes - ) : ?string + ) : string { - if ($excludeAttributes === NULL && $includeAttributes === NULL) { - return NULL; - } - $attributeFilter=""; if ($includeAttributes !== NULL) { $includedIds = $this->getAttributeIds($entityType, $includeAttributes); - if (empty($includedIds)) { - return null; - } else { + if (!empty($includedIds)) { $attributeFilter .= sprintf('AND attribute_id IN(%s)', implode(",",$includedIds)); } } if ($excludeAttributes !== NULL) { $excludedIds = $this->getAttributeIds($entityType, $excludeAttributes); - if (empty($excludedIds)) { - return null; - } else { + if (!empty($excludedIds)) { $attributeFilter .= sprintf('AND attribute_id NOT IN(%s)', implode(",",$excludedIds)); } } diff --git a/Filter/StoreFilter.php b/Filter/StoreFilter.php index b50bce6..a34251d 100644 --- a/Filter/StoreFilter.php +++ b/Filter/StoreFilter.php @@ -46,7 +46,7 @@ public function getStoreFilter(?string $storeCodes) : string $storeIds[] = $storeId; } - return sprintf('AND store_id in(%s)', implode($storeIds)); + return sprintf('AND store_id in(%s)', implode(',', $storeIds)); } else { return ""; } From 50bef0573669219b3bab304c44da6ae8c80dc0de Mon Sep 17 00:00:00 2001 From: Simon Sprankel Date: Mon, 26 Feb 2024 20:01:55 +0100 Subject: [PATCH 7/9] Improve code style --- .../Command/RestoreUseDefaultValueCommand.php | 13 +++++++------ Filter/AttributeFilter.php | 16 ++++++++-------- Filter/StoreFilter.php | 4 ++-- 3 files changed, 17 insertions(+), 16 deletions(-) diff --git a/Console/Command/RestoreUseDefaultValueCommand.php b/Console/Command/RestoreUseDefaultValueCommand.php index 1a9d547..a894dab 100755 --- a/Console/Command/RestoreUseDefaultValueCommand.php +++ b/Console/Command/RestoreUseDefaultValueCommand.php @@ -33,6 +33,7 @@ class RestoreUseDefaultValueCommand extends Command * @var string */ private $storeFilter; + /** * @var AttributeFilter */ @@ -74,19 +75,19 @@ protected function configure() 'store_codes', null, InputArgument::IS_ARRAY, - "Store codes from which attribute values should be removed (csv)", + 'Store codes from which attribute values should be removed (csv)', ) ->addOption( 'exclude_attributes', null, InputArgument::IS_ARRAY, - "Attribute codes from which values should be preserved (csv)", + 'Attribute codes from which values should be preserved (csv)', ) ->addOption( 'include_attributes', null, InputArgument::IS_ARRAY, - "Attribute codes from which values should be removed (csv)", + 'Attribute codes from which values should be removed (csv)', ) ->addOption('always_restore'); } @@ -102,7 +103,7 @@ public function execute(InputInterface $input, OutputInterface $output): int $isAlwaysRestore = $input->getOption('always_restore'); try { - $storeIdFilter=$this->storeFilter->getStoreFilter($storeCodes); + $storeIdFilter = $this->storeFilter->getStoreFilter($storeCodes); } catch (Exception $e) { $output->writeln($e->getMessage()); return Command::FAILURE; @@ -115,7 +116,7 @@ public function execute(InputInterface $input, OutputInterface $output): int } try { - $attributeFilter=$this->attributeFilter->getAttributeFilter($entity, $excludeAttributes, $includeAttributes); + $attributeFilter = $this->attributeFilter->getAttributeFilter($entity, $excludeAttributes, $includeAttributes); } catch (Exception $e) { $output->writeln($e->getMessage()); return Command::FAILURE; @@ -145,7 +146,7 @@ public function execute(InputInterface $input, OutputInterface $output): int foreach ($tables as $table) { // Select all non-global values $fullTableName = $this->resourceConnection->getTableName('catalog_' . $entity . '_entity_' . $table); - $output->writeln(sprintf('Now processing entity `%s` in table `%s`', $entity, $fullTableName )); + $output->writeln(sprintf('Now processing entity `%s` in table `%s`', $entity, $fullTableName)); // NULL values are handled separately $notNullValuesQuery=sprintf( diff --git a/Filter/AttributeFilter.php b/Filter/AttributeFilter.php index ea246fc..2b6e299 100644 --- a/Filter/AttributeFilter.php +++ b/Filter/AttributeFilter.php @@ -35,19 +35,19 @@ public function getAttributeFilter( ?string $includeAttributes ) : string { - $attributeFilter=""; + $attributeFilter = ''; - if ($includeAttributes !== NULL) { + if ($includeAttributes !== null) { $includedIds = $this->getAttributeIds($entityType, $includeAttributes); if (!empty($includedIds)) { - $attributeFilter .= sprintf('AND attribute_id IN(%s)', implode(",",$includedIds)); + $attributeFilter .= sprintf('AND attribute_id IN(%s)', implode(',', $includedIds)); } } - if ($excludeAttributes !== NULL) { + if ($excludeAttributes !== null) { $excludedIds = $this->getAttributeIds($entityType, $excludeAttributes); if (!empty($excludedIds)) { - $attributeFilter .= sprintf('AND attribute_id NOT IN(%s)', implode(",",$excludedIds)); + $attributeFilter .= sprintf('AND attribute_id NOT IN(%s)', implode(',', $excludedIds)); } } @@ -57,14 +57,14 @@ public function getAttributeFilter( private function getAttributeIds(string $entityType, string $attributeCodes): ?array { $attributes = explode(',', $attributeCodes); - $attributeIds=[]; + $attributeIds = []; foreach ($attributes as $attributeCode) { - $attributeId=$this->attribute->getIdByCode("catalog_".$entityType, $attributeCode); + $attributeId = $this->attribute->getIdByCode('catalog_' . $entityType, $attributeCode); if($attributeId === false) { $error = sprintf('Attribute with code `%s` does not exist', $attributeCode); throw new AttributeDoesNotExistException($error); } else { - $attributeIds[]=$attributeId; + $attributeIds[] = $attributeId; } } diff --git a/Filter/StoreFilter.php b/Filter/StoreFilter.php index a34251d..30d3240 100644 --- a/Filter/StoreFilter.php +++ b/Filter/StoreFilter.php @@ -26,7 +26,7 @@ public function __construct(StoreRepositoryInterface $storeRepository) */ public function getStoreFilter(?string $storeCodes) : string { - if ($storeCodes !== NULL) { + if ($storeCodes !== null) { $storeCodesArray = explode(',', $storeCodes); $storeIds=[]; @@ -48,7 +48,7 @@ public function getStoreFilter(?string $storeCodes) : string return sprintf('AND store_id in(%s)', implode(',', $storeIds)); } else { - return ""; + return ''; } } } From 9d4170597ef6a32e220a3ab15a924f826c4e0bbd Mon Sep 17 00:00:00 2001 From: Rutger Rademaker Date: Mon, 26 Feb 2024 20:38:48 +0100 Subject: [PATCH 8/9] Fix error message where it stated ID could not be found, this should be store code instead --- Filter/StoreFilter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Filter/StoreFilter.php b/Filter/StoreFilter.php index 30d3240..89a2672 100644 --- a/Filter/StoreFilter.php +++ b/Filter/StoreFilter.php @@ -39,7 +39,7 @@ public function getStoreFilter(?string $storeCodes) : string try { $storeId = $this->storeRepository->get($storeCode)->getId(); } catch (NoSuchEntityException $e) { - $error = $e->getMessage() . ' | store ID: ' . $storeCode; + $error = sprintf('%s | Store with code `%s` does not exist.', $e->getMessage(), $storeCode); throw new StoreDoesNotExistException($error); } From 6dea1bd5950dc8972778d082b5866c7b1a052770 Mon Sep 17 00:00:00 2001 From: Rutger Rademaker Date: Tue, 5 Mar 2024 16:27:58 +0100 Subject: [PATCH 9/9] Fixes issue where scoped values were not deleted + fixed issue so deleting null values use the attibute and store filters --- .../Command/RestoreUseDefaultValueCommand.php | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/Console/Command/RestoreUseDefaultValueCommand.php b/Console/Command/RestoreUseDefaultValueCommand.php index a894dab..651bffe 100755 --- a/Console/Command/RestoreUseDefaultValueCommand.php +++ b/Console/Command/RestoreUseDefaultValueCommand.php @@ -154,11 +154,13 @@ public function execute(InputInterface $input, OutputInterface $output): int $storeIdFilter, $attributeFilter ); + $output->writeln(sprintf('%s', $notNullValuesQuery)); $query = $dbRead->query($notNullValuesQuery); $iterator = $this->iteratorFactory->create(); - $iterator->walk($query, [function (array $result) use ($column, &$counts, $dbRead, $dbWrite, $fullTableName, $isDryRun, $output, $isAlwaysRestore): void { + $iterator->walk($query, [function (array $result) use ($column, &$counts, $dbRead, $dbWrite, $fullTableName, + $isDryRun, $output, $isAlwaysRestore, $storeIdFilter): void { $row = $result['row']; if (!$isAlwaysRestore) { @@ -169,12 +171,15 @@ public function execute(InputInterface $input, OutputInterface $output): int [$row['attribute_id'], 0, $row[$column], $row['value']] ); } else { - // Select all global values. - $query = $dbRead->query( - 'SELECT * FROM ' . $fullTableName - . ' WHERE attribute_id = ? AND store_id = ? AND ' . $column . ' = ?', - [$row['attribute_id'], 0, $row[$column]] + // Select all scoped values + $selectScopedValuesQuery = sprintf( + 'SELECT * FROM %s WHERE attribute_id = ? %s AND %s = ?', + $fullTableName, + $storeIdFilter, + $column ); + + $query = $dbRead->query($selectScopedValuesQuery, [$row['attribute_id'], $row[$column]]); } $iterator = $this->iteratorFactory->create(); @@ -209,17 +214,15 @@ public function execute(InputInterface $input, OutputInterface $output): int }]); }]); + $nullCountWhereClause = sprintf('WHERE store_id != 0 %s %s AND value IS NULL', $storeIdFilter, $attributeFilter); $nullCount = (int) $dbRead->fetchOne( - 'SELECT COUNT(*) FROM ' . $fullTableName . ' WHERE store_id != 0 AND value IS NULL' + 'SELECT COUNT(*) FROM ' . $fullTableName . ' ' . $nullCountWhereClause ); if (!$isDryRun && $nullCount > 0) { $output->writeln("Deleting $nullCount NULL value(s) from $fullTableName"); // Remove all non-global null values - $removeNullValuesQuery = sprintf('DELETE FROM ' . $fullTableName . ' WHERE store_id != 0 %s %s AND value IS NULL', - $storeIdFilter, - $attributeFilter - ); + $removeNullValuesQuery = 'DELETE FROM ' . $fullTableName . ' ' . $nullCountWhereClause; $output->writeln(sprintf('%s', $removeNullValuesQuery)); $dbWrite->query($removeNullValuesQuery); }