Skip to content

Commit 6f8d279

Browse files
committed
ACP2E-4281: Validation errors on deleted customer attributes when changing customer data.
1 parent 44b494e commit 6f8d279

File tree

7 files changed

+877
-22
lines changed

7 files changed

+877
-22
lines changed

app/code/Magento/Customer/Model/ResourceModel/Address.php

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
<?php
22
/**
3-
* Copyright 2015 Adobe
3+
* Copyright 2011 Adobe
44
* All Rights Reserved.
55
*/
6+
67
namespace Magento\Customer\Model\ResourceModel;
78

8-
use Magento\Customer\Controller\Adminhtml\Group\Delete;
99
use Magento\Customer\Model\CustomerRegistry;
1010
use Magento\Customer\Model\ResourceModel\Address\DeleteRelation;
11+
use Magento\Eav\Model\ResourceModel\OrphanedMultiselectCleaner;
1112
use Magento\Framework\App\ObjectManager;
13+
use Magento\Framework\DataObject;
1214

1315
/**
14-
* Class Address
16+
* Customer address entity resource model
1517
*
16-
* @package Magento\Customer\Model\ResourceModel
1718
* @SuppressWarnings(PHPMD.CouplingBetweenObjects)
1819
*/
1920
class Address extends \Magento\Eav\Model\Entity\VersionControl\AbstractEntity
@@ -28,24 +29,33 @@ class Address extends \Magento\Eav\Model\Entity\VersionControl\AbstractEntity
2829
*/
2930
protected $customerRepository;
3031

32+
/**
33+
* @var OrphanedMultiselectCleaner
34+
*/
35+
private $orphanedMultiselectCleaner;
36+
3137
/**
3238
* @param \Magento\Eav\Model\Entity\Context $context
3339
* @param \Magento\Framework\Model\ResourceModel\Db\VersionControl\Snapshot $entitySnapshot
3440
* @param \Magento\Framework\Model\ResourceModel\Db\VersionControl\RelationComposite $entityRelationComposite
3541
* @param \Magento\Framework\Validator\Factory $validatorFactory
3642
* @param \Magento\Customer\Api\CustomerRepositoryInterface $customerRepository
3743
* @param array $data
44+
* @param OrphanedMultiselectCleaner|null $orphanedMultiselectCleaner
3845
*/
3946
public function __construct(
4047
\Magento\Eav\Model\Entity\Context $context,
4148
\Magento\Framework\Model\ResourceModel\Db\VersionControl\Snapshot $entitySnapshot,
4249
\Magento\Framework\Model\ResourceModel\Db\VersionControl\RelationComposite $entityRelationComposite,
4350
\Magento\Framework\Validator\Factory $validatorFactory,
4451
\Magento\Customer\Api\CustomerRepositoryInterface $customerRepository,
45-
$data = []
52+
$data = [],
53+
?OrphanedMultiselectCleaner $orphanedMultiselectCleaner = null
4654
) {
4755
$this->customerRepository = $customerRepository;
4856
$this->_validatorFactory = $validatorFactory;
57+
$this->orphanedMultiselectCleaner = $orphanedMultiselectCleaner
58+
?? ObjectManager::getInstance()->get(OrphanedMultiselectCleaner::class);
4959
parent::__construct($context, $entitySnapshot, $entityRelationComposite, $data);
5060
}
5161

@@ -76,11 +86,15 @@ public function getEntityType()
7686
/**
7787
* Check customer address before saving
7888
*
79-
* @param \Magento\Framework\DataObject $address
89+
* @param DataObject $address
8090
* @return $this
8191
*/
82-
protected function _beforeSave(\Magento\Framework\DataObject $address)
92+
protected function _beforeSave(DataObject $address)
8393
{
94+
if ($address->getId()) {
95+
$this->cleanOrphanedMultiselectValues($address);
96+
}
97+
8498
parent::_beforeSave($address);
8599

86100
$this->_validate($address);
@@ -91,7 +105,7 @@ protected function _beforeSave(\Magento\Framework\DataObject $address)
91105
/**
92106
* Validate customer address entity
93107
*
94-
* @param \Magento\Framework\DataObject $address
108+
* @param DataObject $address
95109
* @return void
96110
* @throws \Magento\Framework\Validator\Exception When validation failed
97111
*/
@@ -124,7 +138,8 @@ public function delete($object)
124138
/**
125139
* Get instance of DeleteRelation class
126140
*
127-
* @deprecated 101.0.0
141+
* @deprecated 101.0.0 Use dependency injection instead.
142+
* @see DeleteRelation
128143
* @return DeleteRelation
129144
*/
130145
private function getDeleteRelation()
@@ -135,21 +150,33 @@ private function getDeleteRelation()
135150
/**
136151
* Get instance of CustomerRegistry class
137152
*
138-
* @deprecated 101.0.0
153+
* @deprecated 101.0.0 Use dependency injection instead.
154+
* @see CustomerRegistry
139155
* @return CustomerRegistry
140156
*/
141157
private function getCustomerRegistry()
142158
{
143159
return ObjectManager::getInstance()->get(CustomerRegistry::class);
144160
}
145161

162+
/**
163+
* Clean up orphaned multiselect attribute values before validation
164+
*
165+
* @param DataObject $address
166+
* @return void
167+
*/
168+
private function cleanOrphanedMultiselectValues(DataObject $address): void
169+
{
170+
$this->orphanedMultiselectCleaner->cleanEntity($this, $address);
171+
}
172+
146173
/**
147174
* After delete entity process
148175
*
149176
* @param \Magento\Customer\Model\Address $address
150177
* @return $this
151178
*/
152-
protected function _afterDelete(\Magento\Framework\DataObject $address)
179+
protected function _afterDelete(DataObject $address)
153180
{
154181
$customer = $this->getCustomerRegistry()->retrieve($address->getCustomerId());
155182

app/code/Magento/Customer/Model/ResourceModel/Customer.php

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<?php
22
/**
3-
* Copyright 2015 Adobe
3+
* Copyright 2011 Adobe
44
* All Rights Reserved.
55
*/
66
declare(strict_types=1);
@@ -12,6 +12,7 @@
1212
use Magento\Customer\Model\Customer\NotificationStorage;
1313
use Magento\Eav\Model\Entity\Context;
1414
use Magento\Eav\Model\Entity\VersionControl\AbstractEntity;
15+
use Magento\Eav\Model\ResourceModel\OrphanedMultiselectCleaner;
1516
use Magento\Framework\App\Config\ScopeConfigInterface;
1617
use Magento\Framework\App\ObjectManager;
1718
use Magento\Framework\DataObject;
@@ -73,6 +74,11 @@ class Customer extends AbstractEntity
7374
*/
7475
private $encryptor;
7576

77+
/**
78+
* @var OrphanedMultiselectCleaner
79+
*/
80+
private $orphanedMultiselectCleaner;
81+
7682
/**
7783
* Customer constructor.
7884
*
@@ -86,6 +92,7 @@ class Customer extends AbstractEntity
8692
* @param array $data
8793
* @param AccountConfirmation|null $accountConfirmation
8894
* @param EncryptorInterface|null $encryptor
95+
* @param OrphanedMultiselectCleaner|null $orphanedMultiselectCleaner
8996
* @SuppressWarnings(PHPMD.ExcessiveParameterList)
9097
*/
9198
public function __construct(
@@ -98,7 +105,8 @@ public function __construct(
98105
StoreManagerInterface $storeManager,
99106
$data = [],
100107
?AccountConfirmation $accountConfirmation = null,
101-
?EncryptorInterface $encryptor = null
108+
?EncryptorInterface $encryptor = null,
109+
?OrphanedMultiselectCleaner $orphanedMultiselectCleaner = null
102110
) {
103111
parent::__construct($context, $entitySnapshot, $entityRelationComposite, $data);
104112

@@ -107,6 +115,8 @@ public function __construct(
107115
$this->dateTime = $dateTime;
108116
$this->accountConfirmation = $accountConfirmation ?: ObjectManager::getInstance()
109117
->get(AccountConfirmation::class);
118+
$this->orphanedMultiselectCleaner = $orphanedMultiselectCleaner
119+
?? ObjectManager::getInstance()->get(OrphanedMultiselectCleaner::class);
110120
$this->setType('customer');
111121
$this->setConnection('customer_read');
112122
$this->storeManager = $storeManager;
@@ -151,6 +161,10 @@ protected function _beforeSave(DataObject $customer)
151161
}
152162
$customer->getGroupId();
153163

164+
if ($customer->getId()) {
165+
$this->cleanOrphanedMultiselectValues($customer);
166+
}
167+
154168
parent::_beforeSave($customer);
155169

156170
if (!$customer->getEmail()) {
@@ -519,6 +533,17 @@ public function updateSessionCutOff(int $customerId, int $timestamp): void
519533
);
520534
}
521535

536+
/**
537+
* Clean up orphaned multiselect attribute values before validation
538+
*
539+
* @param DataObject $customer
540+
* @return void
541+
*/
542+
private function cleanOrphanedMultiselectValues(DataObject $customer): void
543+
{
544+
$this->orphanedMultiselectCleaner->cleanEntity($this, $customer);
545+
}
546+
522547
/**
523548
* @inheritDoc
524549
*/

app/code/Magento/Eav/Model/Attribute/Data/Multiselect.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,12 @@ public function validateValue($value)
105105
}
106106

107107
if (!empty($value) && $attribute->getSourceModel()) {
108-
$values = is_array($value) ? $value : explode(',', (string) $value);
108+
if (is_array($value)) {
109+
$values = $value;
110+
} else {
111+
$values = preg_split('/[,\n\r]+/', (string) $value);
112+
}
113+
$values = array_map('trim', $values);
109114
$errors = array_merge(
110115
$errors,
111116
$this->validateBySource($values)
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
<?php
2+
/**
3+
* Copyright 2025 Adobe
4+
* All Rights Reserved.
5+
*/
6+
declare(strict_types=1);
7+
8+
namespace Magento\Eav\Model\ResourceModel;
9+
10+
use Magento\Eav\Model\Entity\Attribute\AbstractAttribute;
11+
use Magento\Framework\DataObject;
12+
use Magento\Eav\Model\Entity\AbstractEntity;
13+
use Magento\Framework\Exception\LocalizedException;
14+
15+
/**
16+
* Service class to clean orphaned multiselect attribute values
17+
*/
18+
class OrphanedMultiselectCleaner
19+
{
20+
/**
21+
* Clean orphaned multiselect values from entity
22+
*
23+
* @param AbstractEntity $resource
24+
* @param DataObject $entity
25+
* @return void
26+
*/
27+
public function cleanEntity(AbstractEntity $resource, DataObject $entity): void
28+
{
29+
$resource->loadAllAttributes($entity);
30+
$entityData = $entity->getData();
31+
32+
foreach ($entityData as $attributeCode => $value) {
33+
$this->cleanAttributeValue($resource, $entity, $attributeCode, $value);
34+
}
35+
}
36+
37+
/**
38+
* Clean a single multiselect attribute value
39+
*
40+
* @param AbstractEntity $resource
41+
* @param DataObject $entity
42+
* @param string $attributeCode
43+
* @param mixed $value
44+
* @return void
45+
*/
46+
private function cleanAttributeValue(
47+
AbstractEntity $resource,
48+
DataObject $entity,
49+
string $attributeCode,
50+
mixed $value
51+
): void {
52+
if (empty($value) && $value !== '0') {
53+
return;
54+
}
55+
56+
if ($entity->dataHasChangedFor($attributeCode)) {
57+
return;
58+
}
59+
60+
try {
61+
$attribute = $resource->getAttribute($attributeCode);
62+
if (!$this->isMultiselectAttribute($attribute)) {
63+
return;
64+
}
65+
66+
$values = is_array($value) ? $value : explode(',', (string) $value);
67+
$validValues = $this->filterValidOptionValues($attribute, $values);
68+
69+
if (count($validValues) !== count($values)) {
70+
$entity->unsetData($attributeCode);
71+
$entity->setData($attributeCode, implode(',', $validValues) ?: null);
72+
}
73+
} catch (\Exception $e) {
74+
return;
75+
}
76+
}
77+
78+
/**
79+
* Check if attribute is a multiselect with source model
80+
*
81+
* @param mixed $attribute
82+
* @return bool
83+
*/
84+
private function isMultiselectAttribute($attribute): bool
85+
{
86+
return $attribute
87+
&& $attribute->getFrontendInput() === 'multiselect'
88+
&& $attribute->usesSource();
89+
}
90+
91+
/**
92+
* Filter valid option values from array
93+
*
94+
* @param AbstractAttribute $attribute
95+
* @param array $values
96+
* @return array
97+
* @throws LocalizedException
98+
*/
99+
private function filterValidOptionValues($attribute, array $values): array
100+
{
101+
$validValues = [];
102+
foreach ($values as $optionId) {
103+
$optionId = trim((string) $optionId);
104+
if ($optionId !== '' && $attribute->getSource()->getOptionText($optionId) !== false) {
105+
$validValues[] = $optionId;
106+
}
107+
}
108+
return $validValues;
109+
}
110+
}

0 commit comments

Comments
 (0)