diff --git a/.gitignore b/.gitignore index 22ac069..2e01698 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ +.idea .php_cs.cache -*~ \ No newline at end of file +vendor +composer.lock +*~ diff --git a/CHANGELOG.md b/CHANGELOG.md index abd2328..817f3bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ 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 +### Changed +- Output for `eav:attributes:restore-use-default-value` only shows the table name once. +### Added +- Option to remove scoped attribute values for `eav:attributes:restore-use-default-value` +### 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..651bffe 100755 --- a/Console/Command/RestoreUseDefaultValueCommand.php +++ b/Console/Command/RestoreUseDefaultValueCommand.php @@ -2,10 +2,13 @@ namespace Hackathon\EAVCleaner\Console\Command; +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; 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,22 @@ 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 +52,8 @@ public function __construct( $this->iteratorFactory = $iteratorFactory; $this->productMetaData = $productMetaData; $this->resourceConnection = $resourceConnection; + $this->storeFilter = $storeFilter; + $this->attributeFilter = $attributeFilter; } protected function configure() @@ -53,7 +70,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_restore'); } public function execute(InputInterface $input, OutputInterface $output): int @@ -61,18 +97,36 @@ 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'); + $isAlwaysRestore = $input->getOption('always_restore'); + + try { + $storeIdFilter = $this->storeFilter->getStoreFilter($storeCodes); + } catch (Exception $e) { + $output->writeln($e->getMessage()); + 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; + } + + try { + $attributeFilter = $this->attributeFilter->getAttributeFilter($entity, $excludeAttributes, $includeAttributes); + } catch (Exception $e) { + $output->writeln($e->getMessage()); + 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 +141,46 @@ 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); + $output->writeln(sprintf('Now processing entity `%s` in table `%s`', $entity, $fullTableName)); // NULL values are handled separately - $query = $dbRead->query("SELECT * FROM $fullTableName WHERE store_id != 0 AND value IS NOT NULL"); + $notNullValuesQuery=sprintf( + "SELECT * FROM $fullTableName WHERE store_id != 0 %s %s AND value IS NOT NULL", + $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): void { + $iterator->walk($query, [function (array $result) use ($column, &$counts, $dbRead, $dbWrite, $fullTableName, + $isDryRun, $output, $isAlwaysRestore, $storeIdFilter): 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 (!$isAlwaysRestore) { + // 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 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(); $iterator->walk($query, [function (array $result) use (&$counts, $dbWrite, $fullTableName, $isDryRun, $output, $row): void { @@ -120,9 +195,15 @@ public function execute(InputInterface $input, OutputInterface $output): int } $output->writeln( - 'Deleting value ' . $row['value_id'] . ' "' . $row['value'] . '" in favor of ' - . $result['value_id'] - . ' for attribute ' . $row['attribute_id'] . ' in table ' . $fullTableName + 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']])) { @@ -133,16 +214,17 @@ 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 - $dbWrite->query( - 'DELETE FROM ' . $fullTableName . ' WHERE store_id != 0 AND value IS NULL' - ); + $removeNullValuesQuery = 'DELETE FROM ' . $fullTableName . ' ' . $nullCountWhereClause; + $output->writeln(sprintf('%s', $removeNullValuesQuery)); + $dbWrite->query($removeNullValuesQuery); } if (count($counts)) { diff --git a/Filter/AttributeFilter.php b/Filter/AttributeFilter.php new file mode 100644 index 0000000..2b6e299 --- /dev/null +++ b/Filter/AttributeFilter.php @@ -0,0 +1,73 @@ +attribute = $attribute; + } + + /** + * @param string $entityType + * @param string|null $excludeAttributes + * @param string|null $includeAttributes + * + * @return array|null + */ + public function getAttributeFilter( + string $entityType, + ?string $excludeAttributes, + ?string $includeAttributes + ) : string + { + $attributeFilter = ''; + + if ($includeAttributes !== null) { + $includedIds = $this->getAttributeIds($entityType, $includeAttributes); + if (!empty($includedIds)) { + $attributeFilter .= sprintf('AND attribute_id IN(%s)', implode(',', $includedIds)); + } + } + + if ($excludeAttributes !== null) { + $excludedIds = $this->getAttributeIds($entityType, $excludeAttributes); + if (!empty($excludedIds)) { + $attributeFilter .= sprintf('AND attribute_id NOT IN(%s)', implode(',', $excludedIds)); + } + } + + return $attributeFilter; + } + + 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) { + $error = sprintf('Attribute with code `%s` does not exist', $attributeCode); + throw new AttributeDoesNotExistException($error); + } else { + $attributeIds[] = $attributeId; + } + + } + return $attributeIds; + } +} 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; + } + + /** + * @param string|null $storeCodes + * + * @return string + */ + public function getStoreFilter(?string $storeCodes) : string + { + if ($storeCodes !== null) { + $storeCodesArray = explode(',', $storeCodes); + + $storeIds=[]; + foreach ($storeCodesArray as $storeCode) { + if ($storeCode == 'admin') { + $error = 'Admin values can not be removed!'; + throw new AdminValuesCanNotBeRemovedException($error); + } + + try { + $storeId = $this->storeRepository->get($storeCode)->getId(); + } catch (NoSuchEntityException $e) { + $error = sprintf('%s | Store with code `%s` does not exist.', $e->getMessage(), $storeCode); + throw new StoreDoesNotExistException($error); + } + + $storeIds[] = $storeId; + } + + return sprintf('AND store_id in(%s)', implode(',', $storeIds)); + } else { + return ''; + } + } +} diff --git a/README.md b/README.md index d6208c9..3ffdac2 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,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 +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. + +### 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 +45,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 5993e66..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", @@ -14,5 +15,17 @@ "psr-4": { "Hackathon\\EAVCleaner\\": "" } + }, + "repositories": { + "magento": { + "type": "composer", + "url": "https://repo.magento.com/" + } + }, + "config": { + "allow-plugins": { + "magento/magento-composer-installer": false, + "magento/composer-dependency-version-audit-plugin": false + } } }