Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Introduce optional filter capabilities when restoring default values #24

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
.idea
.php_cs.cache
*~
vendor
composer.lock
*~
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
120 changes: 101 additions & 19 deletions Console/Command/RestoreUseDefaultValueCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,17 +29,31 @@ class RestoreUseDefaultValueCommand extends Command
*/
private $resourceConnection;

/**
* @var string
*/
private $storeFilter;

/**
sprankhub marked this conversation as resolved.
Show resolved Hide resolved
* @var AttributeFilter
*/
private $attributeFilter;

public function __construct(
IteratorFactory $iteratorFactory,
ProductMetaDataInterface $productMetaData,
ResourceConnection $resourceConnection,
StoreFilter $storeFilter,
AttributeFilter $attributeFilter,
string $name = null
) {
parent::__construct($name);

$this->iteratorFactory = $iteratorFactory;
$this->productMetaData = $productMetaData;
$this->resourceConnection = $resourceConnection;
$this->storeFilter = $storeFilter;
$this->attributeFilter = $attributeFilter;
}

protected function configure()
Expand All @@ -53,26 +70,63 @@ 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
{
$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.');
Expand All @@ -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('<info>Now processing entity `%s` in table `%s`</info>', $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('<info>%s</info>', $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 {
Expand All @@ -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']])) {
Expand All @@ -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('<info>%s</info>', $removeNullValuesQuery));
sprankhub marked this conversation as resolved.
Show resolved Hide resolved
$dbWrite->query($removeNullValuesQuery);
}

if (count($counts)) {
Expand Down
73 changes: 73 additions & 0 deletions Filter/AttributeFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<?php

namespace Hackathon\EAVCleaner\Filter;

use Hackathon\EAVCleaner\Filter\Exception\AttributeDoesNotExistException;
use Magento\Eav\Model\ResourceModel\Entity\Attribute;
use Magento\Eav\Setup\EavSetupFactory;

class AttributeFilter
{
/**
* @var EavSetupFactory
*/
private $attribute;

/**
* @param Attribute $attribute
*/
public function __construct(
Attribute $attribute
) {
$this->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;
}
}
10 changes: 10 additions & 0 deletions Filter/Exception/AdminValuesCanNotBeRemovedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Hackathon\EAVCleaner\Filter\Exception;

use Symfony\Component\Console\Exception\InvalidOptionException;

class AdminValuesCanNotBeRemovedException extends InvalidOptionException
{

}
10 changes: 10 additions & 0 deletions Filter/Exception/AttributeDoesNotExistException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Hackathon\EAVCleaner\Filter\Exception;

use Symfony\Component\Console\Exception\InvalidOptionException;

class AttributeDoesNotExistException extends InvalidOptionException
{

}
10 changes: 10 additions & 0 deletions Filter/Exception/StoreDoesNotExistException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Hackathon\EAVCleaner\Filter\Exception;

use Symfony\Component\Console\Exception\InvalidOptionException;

class StoreDoesNotExistException extends InvalidOptionException
{

}
54 changes: 54 additions & 0 deletions Filter/StoreFilter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace Hackathon\EAVCleaner\Filter;

use Hackathon\EAVCleaner\Filter\Exception\AdminValuesCanNotBeRemovedException;
use Hackathon\EAVCleaner\Filter\Exception\StoreDoesNotExistException;
use Magento\Framework\Exception\NoSuchEntityException;
use Magento\Store\Api\StoreRepositoryInterface;

class StoreFilter
{
/**
* @var StoreRepositoryInterface
*/
private $storeRepository;

public function __construct(StoreRepositoryInterface $storeRepository)
{
$this->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 '';
}
}
}
Loading