├── .gitignore ├── registration.php ├── etc ├── module.xml └── di.xml ├── composer.json ├── CHANGELOG.md ├── LICENSE ├── README.md └── Console └── Command ├── CleanUpAttributesAndValuesWithoutParentCommand.php ├── RemoveUnusedAttributesCommand.php ├── RestoreUseDefaultConfigValueCommand.php ├── RestoreUseDefaultValueCommand.php └── RemoveUnusedMediaCommand.php /.gitignore: -------------------------------------------------------------------------------- 1 | .php_cs.cache 2 | *~ -------------------------------------------------------------------------------- /registration.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "magento-hackathon/module-eavcleaner-m2", 3 | "description": "Purpose of this project is to check for different flaws that can occur due to EAV and provide cleanup functions.", 4 | "require": { 5 | "php": "~7.3||~8.0", 6 | "magento/magento2-base": "~2.3" 7 | }, 8 | "license": "MIT", 9 | "type": "magento2-module", 10 | "autoload": { 11 | "files": [ 12 | "registration.php" 13 | ], 14 | "psr-4": { 15 | "Hackathon\\EAVCleaner\\": "" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## 1.2.1 - 2021-10-28 8 | ### Added 9 | - Add license 10 | 11 | ## 1.2.0 - 2021-10-27 12 | ### Changed 13 | - Refactor code 14 | - Change Composer name 15 | 16 | ## 1.1.0 - 2019-12-20 17 | ### Changed 18 | - [#4](https://github.com/Vendic/EAVCleaner/pull/4) Added non interactive mode support [@boehsermoe](https://github.com/boehsermoe) 19 | 20 | ## 1.0.4 - 2019-09-12 21 | ### Changed 22 | - [#5](https://github.com/Vendic/EAVCleaner/pull/5) Refactored command `eav:media:remove-unused` 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 FireGento e.V. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /etc/di.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Hackathon\EAVCleaner\Console\Command\RestoreUseDefaultValueCommand 7 | Hackathon\EAVCleaner\Console\Command\RestoreUseDefaultConfigValueCommand 8 | Hackathon\EAVCleaner\Console\Command\RemoveUnusedMediaCommand 9 | Hackathon\EAVCleaner\Console\Command\RemoveUnusedAttributesCommand 10 | Hackathon\EAVCleaner\Console\Command\CleanUpAttributesAndValuesWithoutParentCommand 11 | 12 | 13 | 14 | 15 | 16 | Magento\Framework\Filesystem\Driver\File 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Magento 2 EAV Cleaner Console Command 2 | 3 | Purpose of this project is to check for different flaws that can occur due to EAV and provide cleanup functions. 4 | 5 | ## Usage 6 | 7 | Run `bin/magento` in the Magento 2 root and look for the `eav:` commands. 8 | 9 | ## Commands 10 | 11 | * `eav:config:restore-use-default-value` Check if config admin value and storeview value are the same, so "use default" doesn't work anymore. Delete the storeview values. 12 | * `eav:attributes:restore-use-default-value` Check if product attribute admin value and storeview value are the same, so "use default" doesn't work anymore. Delete the storeview values. 13 | * `eav:attributes:remove-unused` Remove attributes with no values set in products and attributes that are not present in any attribute sets. 14 | * `eav:media:remove-unused` Remove unused product images. 15 | * `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`. 16 | 17 | ## Dry run 18 | Use `--dry-run` to check result without modifying data. 19 | 20 | ## Force 21 | Use `--force` to skip the confirmation prompt before modifying data. 22 | 23 | ## Installation 24 | Installation with composer: 25 | 26 | ```bash 27 | composer require magento-hackathon/module-eavcleaner-m2 28 | ``` 29 | 30 | ### Contributors 31 | - Nikita Zhavoronkova 32 | - Anastasiia Sukhorukova 33 | - Peter Jaap Blaakmeer 34 | 35 | ### Special thanks to 36 | - Benno Lippert 37 | - Damian Luszczymak 38 | - Joke Puts 39 | - Ralf Siepker 40 | -------------------------------------------------------------------------------- /Console/Command/CleanUpAttributesAndValuesWithoutParentCommand.php: -------------------------------------------------------------------------------- 1 | resourceConnection = $resourceConnection; 42 | $this->eavEntityTypeCollectionFactory = $eavEntityTypeCollectionFactory; 43 | } 44 | 45 | /** 46 | * @inheritdoc 47 | */ 48 | protected function configure() 49 | { 50 | //phpcs:ignore Generic.Files.LineLength.TooLong 51 | $description = 'Remove orphaned attribute values - those which are missing a parent entry (with the corresponding backend_type) in eav_attribute'; 52 | $this 53 | ->setName('eav:clean:attributes-and-values-without-parent') 54 | ->setDescription($description) 55 | ->addOption('dry-run') 56 | ->addOption('force'); 57 | } 58 | 59 | /** 60 | * @inheritdoc 61 | */ 62 | public function execute(InputInterface $input, OutputInterface $output): int 63 | { 64 | $isDryRun = $input->getOption('dry-run'); 65 | $isForce = $input->getOption('force'); 66 | 67 | if (!$isDryRun && !$isForce) { 68 | if (!$input->isInteractive()) { 69 | $output->writeln( 70 | '' 71 | //phpcs:ignore Generic.Files.LineLength.TooLong 72 | . 'ERROR: neither --dry-run nor --force options were supplied, and we are not running interactively.' 73 | . '' 74 | ); 75 | 76 | return Command::FAILURE; 77 | } 78 | 79 | $output->writeln( 80 | 'WARNING: this is not a dry run. If you want to do a dry-run, add --dry-run.' 81 | ); 82 | $question = new ConfirmationQuestion('Are you sure you want to continue? [No] ', false); 83 | 84 | if (!$this->getHelper('question')->ask($input, $output, $question)) { 85 | return Command::FAILURE; 86 | } 87 | } 88 | 89 | $db = $this->resourceConnection->getConnection(); 90 | $types = ['varchar', 'int', 'decimal', 'text', 'datetime']; 91 | $entityTypeCodes = [ 92 | ProductAttributeInterface::ENTITY_TYPE_CODE, 93 | CategoryAttributeInterface::ENTITY_TYPE_CODE, 94 | CustomerMetadataInterface::ENTITY_TYPE_CUSTOMER, 95 | AddressMetadataInterface::ENTITY_TYPE_ADDRESS 96 | ]; 97 | 98 | $eavTable = $db->getTableName('eav_attribute'); 99 | 100 | foreach ($entityTypeCodes as $code) { 101 | $entityType = $this->eavEntityTypeCollectionFactory 102 | ->create() 103 | ->addFieldToFilter('entity_type_code', $code) 104 | ->getFirstItem(); 105 | $output->writeln('' . sprintf('Cleaning values for %s', $code) . ''); 106 | 107 | foreach ($types as $type) { 108 | $entityValueTable = $this->resourceConnection->getTableName(sprintf('%s_entity_%s', $code, $type)); 109 | 110 | $select = $db->select() 111 | ->from($entityValueTable, ['COUNT(*)']) 112 | ->where('attribute_id NOT IN (?)', new \Zend_Db_Expr( 113 | $db->select() 114 | ->from($eavTable, ['attribute_id']) 115 | ->where('entity_type_id = ?', $entityType->getEntityTypeId()) 116 | ->where('backend_type = ?', $type) 117 | )); 118 | $count = (int)$db->fetchOne($select); 119 | $output->writeln("Clean up $count rows in $entityValueTable"); 120 | 121 | if (!$isDryRun && $count > 0) { 122 | $db->delete($entityValueTable, [ 123 | 'attribute_id NOT IN (?)' => new \Zend_Db_Expr( 124 | $db->select() 125 | ->from($eavTable, ['attribute_id']) 126 | ->where('entity_type_id = ?', $entityType->getEntityTypeId()) 127 | ->where('backend_type = ?', $type) 128 | ) 129 | ]); 130 | } 131 | } 132 | } 133 | 134 | return Command::SUCCESS; 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /Console/Command/RemoveUnusedAttributesCommand.php: -------------------------------------------------------------------------------- 1 | resourceConnection = $resourceConnection; 47 | $this->attributeRepository = $attributeRepository; 48 | $this->searchCriteriaBuilderFactory = $searchCriteriaBuilderFactory; 49 | } 50 | 51 | /** 52 | * @inheritdoc 53 | */ 54 | protected function configure() 55 | { 56 | $this 57 | ->setName('eav:attributes:remove-unused') 58 | ->setDescription('Remove unused attributes (without values or not assigned to any attribute set') 59 | ->addOption('dry-run') 60 | ->addOption('force'); 61 | } 62 | 63 | /** 64 | * @inheritdoc 65 | */ 66 | public function execute(InputInterface $input, OutputInterface $output): int 67 | { 68 | $isDryRun = $input->getOption('dry-run'); 69 | $isForce = $input->getOption('force'); 70 | 71 | if (!$isDryRun && !$isForce) { 72 | if (!$input->isInteractive()) { 73 | $output->writeln( 74 | '' 75 | //phpcs:ignore Generic.Files.LineLength.TooLong 76 | . 'ERROR: neither --dry-run nor --force options were supplied, and we are not running interactively.' 77 | . '' 78 | ); 79 | 80 | return Command::FAILURE; 81 | } 82 | 83 | $output->writeln( 84 | 'WARNING: this is not a dry run. If you want to do a dry-run, add --dry-run.' 85 | ); 86 | $question = new ConfirmationQuestion('Are you sure you want to continue? [No] ', false); 87 | 88 | if (!$this->getHelper('question')->ask($input, $output, $question)) { 89 | return Command::FAILURE; 90 | } 91 | } 92 | 93 | $db = $this->resourceConnection->getConnection('core_write'); 94 | 95 | $searchCriteria = $this->searchCriteriaBuilderFactory->create() 96 | ->addFilter('is_user_defined', 1) 97 | ->addFilter('backend_type', 'static', 'neq') 98 | ->create(); 99 | $attributes = $this->attributeRepository 100 | ->getList(ProductAttributeInterface::ENTITY_TYPE_CODE, $searchCriteria) 101 | ->getItems(); 102 | $eavAttributeTable = $this->resourceConnection->getTableName('eav_attribute'); 103 | $eavEntityAttributeTable = $this->resourceConnection->getTableName('eav_entity_attribute'); 104 | 105 | $deleted = 0; 106 | foreach ($attributes as $attribute) { 107 | $table = $this->resourceConnection 108 | ->getTableName(sprintf('catalog_product_entity_%s', $attribute->getBackendType())); 109 | /* Look for attributes that have no values set in products */ 110 | $select = $db->select() 111 | ->from($table, ['COUNT(*)']) 112 | ->where('attribute_id = ?', $attribute->getAttributeId()); 113 | $attributeValues = (int)$db->fetchOne($select); 114 | 115 | if ($attributeValues === 0) { 116 | $output->writeln( 117 | sprintf('%s has %d values; deleting attribute', $attribute->getAttributeCode(), $attributeValues) 118 | ); 119 | 120 | if (!$isDryRun) { 121 | $db->delete($eavAttributeTable, ['attribute_code = ?' => $attribute->getAttributeCode()]); 122 | } 123 | 124 | $deleted++; 125 | } else { 126 | /* Look for attributes that are not assigned to attribute sets */ 127 | $select = $db->select() 128 | ->from($eavEntityAttributeTable, ['COUNT(*)']) 129 | ->where('attribute_id = ?', $attribute->getAttributeId()); 130 | $attributeGroups = (int)$db->fetchOne($select); 131 | 132 | if ($attributeGroups === 0) { 133 | $output->writeln( 134 | sprintf( 135 | '%s is not assigned to any attribute set; deleting attribute and its %d orphaned value(s)', 136 | $attribute->getAttributeCode(), 137 | $attributeValues 138 | ) 139 | ); 140 | 141 | if (!$isDryRun) { 142 | $db->delete($eavAttributeTable, ['attribute_code = ?' => $attribute->getAttributeCode()]); 143 | } 144 | 145 | $deleted++; 146 | } 147 | } 148 | } 149 | 150 | $output->writeln(sprintf('Deleted %d attributes.', $deleted)); 151 | 152 | return Command::SUCCESS; 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /Console/Command/RestoreUseDefaultConfigValueCommand.php: -------------------------------------------------------------------------------- 1 | configReader = $configReader; 53 | $this->iteratorFactory = $iteratorFactory; 54 | $this->resourceConnection = $resourceConnection; 55 | } 56 | 57 | /** 58 | * @inheritdoc 59 | */ 60 | protected function configure() 61 | { 62 | $description = "Restore config's 'Use Default Value' if the non-global value is the same as the global value"; 63 | $this 64 | ->setName('eav:config:restore-use-default-value') 65 | ->setDescription($description) 66 | ->addOption('dry-run') 67 | ->addOption('force'); 68 | } 69 | 70 | /** 71 | * @inheritdoc 72 | */ 73 | public function execute(InputInterface $input, OutputInterface $output): int 74 | { 75 | $isDryRun = $input->getOption('dry-run'); 76 | $isForce = $input->getOption('force'); 77 | 78 | if (!$isDryRun && !$isForce) { 79 | if (!$input->isInteractive()) { 80 | $output->writeln( 81 | '' 82 | //phpcs:ignore Generic.Files.LineLength.TooLong 83 | . 'ERROR: neither --dry-run nor --force options were supplied, and we are not running interactively.' 84 | . '' 85 | ); 86 | 87 | return Command::FAILURE; 88 | } 89 | 90 | $output->writeln( 91 | 'WARNING: this is not a dry run. If you want to do a dry-run, add --dry-run.' 92 | ); 93 | $question = new ConfirmationQuestion('Are you sure you want to continue? [No] ', false); 94 | 95 | if (!$this->getHelper('question')->ask($input, $output, $question)) { 96 | return Command::FAILURE; 97 | } 98 | } 99 | 100 | $removedConfigValues = 0; 101 | 102 | $dbRead = $this->resourceConnection->getConnection('core_read'); 103 | $dbWrite = $this->resourceConnection->getConnection('core_write'); 104 | $tableName = $this->resourceConnection->getTableName('core_config_data'); 105 | 106 | $query = $dbRead->select() 107 | ->distinct() 108 | ->from($tableName, ['path', 'value']) 109 | ->where('scope_id = ?', 0); 110 | 111 | $iterator = $this->iteratorFactory->create(); 112 | $iterator->walk($query, [ 113 | function (array $result) use ( 114 | $dbRead, 115 | $dbWrite, 116 | $isDryRun, 117 | $output, 118 | &$removedConfigValues, 119 | $tableName 120 | ): void { 121 | $config = $result['row']; 122 | 123 | $count = (int)$dbRead->fetchOne( 124 | $dbRead->select() 125 | ->from($tableName, ['COUNT(*)']) 126 | ->where('path = ?', $config['path']) 127 | ->where('BINARY value = ?', $config['value']) 128 | ); 129 | 130 | if ($count > 1) { 131 | $output->writeln( 132 | sprintf( 133 | 'Config path %s with value %s has %d values; deleting non-default values', 134 | $config['path'], 135 | $config['value'], 136 | $count 137 | ) 138 | ); 139 | 140 | if (!$isDryRun) { 141 | $dbWrite->delete( 142 | $tableName, 143 | [ 144 | 'path = ?' => $config['path'], 145 | 'BINARY value = ?' => $config['value'], 146 | 'scope_id != ?' => 0 147 | ] 148 | ); 149 | } 150 | 151 | $removedConfigValues += ($count - 1); 152 | } 153 | 154 | if ($config['value'] === $this->getSystemValue($config['path'])) { 155 | $output->writeln( 156 | sprintf( 157 | 'Config path %s with value %s matches system default; deleting value', 158 | $config['path'], 159 | $config['value'] 160 | ) 161 | ); 162 | // Remove the non-global value 163 | if (!$isDryRun) { 164 | $conditions = ['path = ?' => $config['path']]; 165 | if ($config['value'] === null) { 166 | $conditions['value IS NULL'] = null; 167 | } else { 168 | $conditions['BINARY value = ?'] = $config['value']; 169 | } 170 | $conditions['scope_id = ?'] = 0; 171 | 172 | $dbWrite->delete($tableName, $conditions); 173 | } 174 | 175 | $removedConfigValues++; 176 | } 177 | } 178 | ]); 179 | 180 | $output->writeln('Removed ' . $removedConfigValues . ' values from core_config_data table.'); 181 | 182 | return Command::SUCCESS; 183 | } 184 | 185 | /** 186 | * Retrieve the system value for a given configuration path 187 | * 188 | * @param string $path 189 | * @return mixed 190 | * @throws LocalizedException 191 | */ 192 | private function getSystemValue(string $path) 193 | { 194 | if (!isset($this->systemConfig)) { 195 | $this->systemConfig = $this->configReader->read()['data']['default']; 196 | } 197 | 198 | $pathParts = explode('/', $path); 199 | $value = $this->systemConfig; 200 | 201 | while (!empty($pathParts)) { 202 | $value = $value[array_shift($pathParts)] ?? null; 203 | } 204 | 205 | return $value; 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /Console/Command/RestoreUseDefaultValueCommand.php: -------------------------------------------------------------------------------- 1 | iteratorFactory = $iteratorFactory; 49 | $this->productMetaData = $productMetaData; 50 | $this->resourceConnection = $resourceConnection; 51 | } 52 | 53 | /** 54 | * @inheritdoc 55 | */ 56 | protected function configure() 57 | { 58 | $description = "Restore product's 'Use Default Value' if the non-global value is the same as the global value"; 59 | $this 60 | ->setName('eav:attributes:restore-use-default-value') 61 | ->setDescription($description) 62 | ->addOption('dry-run') 63 | ->addOption('force') 64 | ->addOption( 65 | 'entity', 66 | null, 67 | InputOption::VALUE_OPTIONAL, 68 | 'Set entity to cleanup (product or category)', 69 | 'product' 70 | ); 71 | } 72 | 73 | /** 74 | * @inheritdoc 75 | */ 76 | public function execute(InputInterface $input, OutputInterface $output): int 77 | { 78 | $isDryRun = $input->getOption('dry-run'); 79 | $isForce = $input->getOption('force'); 80 | $entity = $input->getOption('entity'); 81 | 82 | if (!in_array($entity, ['product', 'category'])) { 83 | $output->writeln( 84 | 'Please specify the entity with --entity. Possible options are product or category' 85 | ); 86 | 87 | return Command::FAILURE; 88 | } 89 | 90 | if (!$isDryRun && !$isForce) { 91 | if (!$input->isInteractive()) { 92 | $output->writeln( 93 | '' 94 | //phpcs:ignore Generic.Files.LineLength.TooLong 95 | . 'ERROR: neither --dry-run nor --force options were supplied, and we are not running interactively.' 96 | . '' 97 | ); 98 | 99 | return Command::FAILURE; 100 | } 101 | 102 | $output->writeln( 103 | 'WARNING: this is not a dry run. If you want to do a dry-run, add --dry-run.' 104 | ); 105 | $question = new ConfirmationQuestion('Are you sure you want to continue? [No] ', false); 106 | 107 | if (!$this->getHelper('question')->ask($input, $output, $question)) { 108 | return Command::FAILURE; 109 | } 110 | } 111 | 112 | $dbRead = $this->resourceConnection->getConnection('core_read'); 113 | $dbWrite = $this->resourceConnection->getConnection('core_write'); 114 | $counts = []; 115 | $column = $this->productMetaData->getEdition() !== ProductMetadata::EDITION_NAME ? 'row_id' : 'entity_id'; 116 | 117 | foreach (['varchar', 'int', 'decimal', 'text', 'datetime'] as $table) { 118 | // Select all non-global values 119 | $fullTableName = $this->resourceConnection->getTableName(sprintf('catalog_%s_entity_%s', $entity, $table)); 120 | 121 | // NULL values are handled separately 122 | $query = $dbRead->select() 123 | ->from($fullTableName) 124 | ->where('store_id != ?', 0) 125 | ->where('value IS NOT NULL'); 126 | 127 | $iterator = $this->iteratorFactory->create(); 128 | $iterator->walk($query, [ 129 | function (array $result) use ( 130 | $column, 131 | &$counts, 132 | $dbRead, 133 | $dbWrite, 134 | $fullTableName, 135 | $isDryRun, 136 | $output 137 | ): void { 138 | $row = $result['row']; 139 | 140 | // Select the global value if it's the same as the non-global value 141 | $query = $dbRead->select() 142 | ->from($fullTableName) 143 | ->where('attribute_id = ?', $row['attribute_id']) 144 | ->where('store_id = ?', 0) 145 | ->where($column . ' = ?', $row[$column]) 146 | ->where('BINARY value = ?', $row['value']); 147 | 148 | $iterator = $this->iteratorFactory->create(); 149 | $iterator->walk($query, [ 150 | function (array $result) use ( 151 | &$counts, 152 | $dbWrite, 153 | $fullTableName, 154 | $isDryRun, 155 | $output, 156 | $row 157 | ): void { 158 | $result = $result['row']; 159 | 160 | if (!$isDryRun) { 161 | // Remove the non-global value 162 | $dbWrite->delete($fullTableName, ['value_id = ?' => $row['value_id']]); 163 | } 164 | 165 | $output->writeln( 166 | sprintf( 167 | 'Deleting value %s "%s" in favor of %s for attribute %s in table %s', 168 | $row['value_id'], 169 | $row['value'], 170 | $result['value_id'], 171 | $row['attribute_id'], 172 | $fullTableName 173 | ) 174 | ); 175 | 176 | if (!isset($counts[$row['attribute_id']])) { 177 | $counts[$row['attribute_id']] = 0; 178 | } 179 | 180 | $counts[$row['attribute_id']]++; 181 | } 182 | ]); 183 | } 184 | ]); 185 | 186 | $nullCountSelect = (int)$dbRead->select() 187 | ->from($fullTableName, ['count' => new \Zend_Db_Expr('COUNT(*)')]) 188 | ->where('store_id != ? AND value IS NULL', 0); 189 | $nullCount = (int)$dbRead->fetchOne($nullCountSelect); 190 | 191 | if (!$isDryRun && $nullCount > 0) { 192 | $output->writeln(sprintf('Deleting %d NULL value(s) from %s', $nullCount, $fullTableName)); 193 | // Remove all non-global null values 194 | $dbWrite->delete($fullTableName, ['store_id != ?' => 0, 'value IS NULL']); 195 | } 196 | 197 | if (count($counts)) { 198 | $output->writeln('Done'); 199 | } else { 200 | $output->writeln('There were no attribute values to clean up'); 201 | } 202 | } 203 | 204 | return Command::SUCCESS; 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /Console/Command/RemoveUnusedMediaCommand.php: -------------------------------------------------------------------------------- 1 | resourceConnection = $resourceConnection; 58 | $this->filesystem = $filesystem; 59 | $this->driver = $driver; 60 | } 61 | 62 | /** 63 | * @inheritdoc 64 | */ 65 | protected function configure(): void 66 | { 67 | $this->setName(self::COMMAND_NAME_EAV_MEDIA_REMOVE_UNUSED); 68 | $this->setDescription('Remove unused product images'); 69 | $this->addOption( 70 | self::OPTION_INCLUDING_CACHE, 71 | 'c', 72 | null, 73 | 'Also clear the ./cache/* entries for the corresponding images' 74 | ); 75 | $this->addOption( 76 | self::OPTION_ONLY_CACHE, 77 | 'k', 78 | null, 79 | 'Clear only the ./cache/* entries for the corresponding images, but not the corresponding images' 80 | ); 81 | $this->addOption( 82 | self::OPTION_INCLUDING_RELATION_ENTITY, 83 | 'r', 84 | null, 85 | 'Also clear the media not in relation table "catalog_product_entity_media_gallery_value_to_entity"' 86 | ); 87 | $this->addOption( 88 | self::OPTION_DRY_RUN, 89 | 'd', 90 | null, 91 | 'Only process files and output what would be deleted, but don\'t delete anything' 92 | ); 93 | $this->addOption( 94 | self::OPTION_FORCE, 95 | 'f', 96 | null, 97 | 'Prevent confirmation question and force execution. Option is required for non-interactive execution.' 98 | ); 99 | } 100 | 101 | /** 102 | * @inheritdoc 103 | */ 104 | public function execute(InputInterface $input, OutputInterface $output): int 105 | { 106 | $fileSize = 0; 107 | $countFiles = 0; 108 | $isForce = $input->getOption(self::OPTION_FORCE); 109 | $isDryRun = (bool)$input->getOption(self::OPTION_DRY_RUN); 110 | $deleteCacheAsWell = $input->getOption(self::OPTION_INCLUDING_CACHE); 111 | $deleteOnlyCache = $input->getOption(self::OPTION_ONLY_CACHE); 112 | if ($deleteOnlyCache) { 113 | $deleteCacheAsWell = true; 114 | } 115 | $deleteNotInRelation = $input->getOption(self::OPTION_INCLUDING_RELATION_ENTITY); 116 | 117 | if (!$isDryRun && !$isForce) { 118 | if (!$input->isInteractive()) { 119 | $output->writeln( 120 | sprintf( 121 | 'ERROR: neither --%s nor --%s options were supplied, and we are not running interactively.', 122 | self::OPTION_DRY_RUN, 123 | self::OPTION_FORCE 124 | ) 125 | ); 126 | 127 | return Cli::RETURN_FAILURE; 128 | } 129 | 130 | $output->writeln( 131 | sprintf( 132 | 'WARNING: this is not a dry run. If you want to do a dry-run, add --%s.', 133 | self::OPTION_DRY_RUN 134 | ) 135 | ); 136 | 137 | $question = new ConfirmationQuestion('Are you sure you want to continue? [No]', false); 138 | 139 | if (!$this->getHelper('question')->ask($input, $output, $question)) { 140 | return Cli::RETURN_FAILURE; 141 | } 142 | } 143 | 144 | $imageDir = $this->getImageDir(); 145 | $connection = $this->resourceConnection->getConnection('core_read'); 146 | $mediaGalleryTable = $this->resourceConnection->getTableName('catalog_product_entity_media_gallery'); 147 | 148 | $directoryIterator = new RecursiveDirectoryIterator($imageDir); 149 | 150 | $imagesToKeep = $connection->select() 151 | ->from($mediaGalleryTable, ['value']) 152 | ->query() 153 | ->fetchAll(\Zend_Db::FETCH_COLUMN); 154 | 155 | if ($deleteNotInRelation) { 156 | $mediaGalleryToEntityTable = 157 | $this->resourceConnection->getTableName('catalog_product_entity_media_gallery_value_to_entity'); 158 | $select = $connection->select() 159 | ->from(['mg' => $mediaGalleryTable], ['value']) 160 | ->where( 161 | 'value_id IN (?)', 162 | $connection->select() 163 | ->from(['mge' => $mediaGalleryToEntityTable], ['value_id']) 164 | ); 165 | $imagesToKeep = $connection->fetchCol($select); 166 | } 167 | 168 | $imagesToKeep = array_flip($imagesToKeep); 169 | 170 | foreach (new RecursiveIteratorIterator($directoryIterator) as $file) { 171 | // Directory guard 172 | if ($this->driver->isDirectory($file)) { 173 | continue; 174 | } 175 | 176 | // Cached guard 177 | if ($this->isInCachePath($file) && !$deleteCacheAsWell) { 178 | continue; 179 | } 180 | 181 | // Original image guard if option --only-cache 182 | if (!$this->isInCachePath($file) && $deleteOnlyCache) { 183 | continue; 184 | } 185 | 186 | $filePath = str_replace($imageDir, "", $file); 187 | // Filepath guard 188 | if (empty($filePath)) { 189 | continue; 190 | } 191 | 192 | $filePathWithoutCacheDir = preg_replace('#/cache_*/[a-z0-9]+(/[a-z0-9]/[a-z0-9]/.+?)#i', '$1', $filePath); 193 | if (array_key_exists($filePathWithoutCacheDir, $imagesToKeep)) { 194 | continue; 195 | } 196 | 197 | // Placeholder guard 198 | if ($this->isInPlaceholderPath($file)) { 199 | continue; 200 | } 201 | 202 | if (array_key_exists($filePath, $imagesToKeep)) { 203 | continue; 204 | } 205 | 206 | try { 207 | $fileSize += $this->driver->stat($file)['size']; 208 | $countFiles++; 209 | 210 | if (!$isDryRun) { 211 | $this->driver->deleteFile($file); 212 | $output->writeln('## REMOVING: ' . $filePath . ' ##'); 213 | } else { 214 | $output->writeln('## WOULD REMOVE: ' . $filePath . ' ##'); 215 | } 216 | } catch (FileSystemException $e) { 217 | $output->writeln('## ERROR: ' . $e->getMessage() . ' ##'); 218 | continue; 219 | } 220 | 221 | } 222 | 223 | $this->printResult($output, $isDryRun, $countFiles, $fileSize); 224 | 225 | return Cli::RETURN_SUCCESS; 226 | } 227 | 228 | /** 229 | * Get the media directory path 230 | * 231 | * @return string 232 | */ 233 | private function getImageDir(): string 234 | { 235 | $directory = $this->filesystem->getDirectoryRead(DirectoryList::MEDIA); 236 | 237 | return $directory->getAbsolutePath() . DIRECTORY_SEPARATOR . 'catalog' . DIRECTORY_SEPARATOR . 'product'; 238 | } 239 | 240 | /** 241 | * Check if the file is in the cache path 242 | * 243 | * @param string|null $file 244 | * @return bool 245 | */ 246 | private function isInCachePath(?string $file): bool 247 | { 248 | return strpos($file, '/cache') !== false; 249 | } 250 | 251 | /** 252 | * Check if the file is in the placeholder path 253 | * 254 | * @param string|null $file 255 | * @return bool 256 | */ 257 | private function isInPlaceholderPath(?string $file): bool 258 | { 259 | return strpos($file, '/placeholder') !== false; 260 | } 261 | 262 | /** 263 | * Print the result of the command 264 | * 265 | * @param OutputInterface $output 266 | * @param bool $isDryRun 267 | * @param int $countFiles 268 | * @param int $filesize 269 | */ 270 | private function printResult(OutputInterface $output, bool $isDryRun, int $countFiles, int $filesize): void 271 | { 272 | $actionName = $isDryRun ? 'Would delete' : 'Deleted'; 273 | $fileSizeInMB = number_format($filesize / 1024 / 1024, '2'); 274 | 275 | $output->writeln("{$actionName} {$countFiles} unused images. {$fileSizeInMB} MB"); 276 | } 277 | } 278 | --------------------------------------------------------------------------------