├── .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 |
--------------------------------------------------------------------------------