diff --git a/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/EavAttributeCondition.php b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/EavAttributeCondition.php
index 3edfb986001bf..399643e83cc90 100644
--- a/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/EavAttributeCondition.php
+++ b/app/code/Magento/Catalog/Model/Api/SearchCriteria/CollectionProcessor/ConditionProcessor/ConditionBuilder/EavAttributeCondition.php
@@ -53,31 +53,52 @@ public function build(Filter $filter): string
{
$attribute = $this->getAttributeByCode($filter->getField());
$tableAlias = 'ca_' . $attribute->getAttributeCode();
+ $entityIdField = $attribute->getEntityIdField();
+ $isNegative = $this->isNegativeConditionType($filter->getConditionType());
$conditionType = $this->mapConditionType($filter->getConditionType());
+ if ($isNegative) {
+ $conditionType = $this->mapToPositiveConditionType($conditionType);
+ }
$conditionValue = $this->mapConditionValue($conditionType, $filter->getValue());
// NOTE: store scope was ignored intentionally to perform search across all stores
- if ($conditionType == 'is_null') {
+ if ($conditionType === 'is_null') {
$entityResourceModel = $attribute->getEntity();
$attributeSelect = $this->resourceConnection->getConnection()
->select()
->from(
[Collection::MAIN_TABLE_ALIAS => $entityResourceModel->getEntityTable()],
- Collection::MAIN_TABLE_ALIAS . '.' . $attribute->getEntityIdField()
+ Collection::MAIN_TABLE_ALIAS . '.' . $entityIdField
)->joinLeft(
[$tableAlias => $attribute->getBackendTable()],
- $tableAlias . '.' . $attribute->getEntityIdField() . '=' . Collection::MAIN_TABLE_ALIAS .
- '.' . $attribute->getEntityIdField() . ' AND ' . $tableAlias . '.' .
+ $tableAlias . '.' . $entityIdField . '=' . Collection::MAIN_TABLE_ALIAS .
+ '.' . $entityIdField . ' AND ' . $tableAlias . '.' .
$attribute->getIdFieldName() . '=' . $attribute->getAttributeId(),
''
- )->where($tableAlias . '.value is null');
+ )->where(
+ $tableAlias . '.value IS NULL OR ' . $tableAlias . '.value = ?',
+ ''
+ );
+ } elseif ($conditionType === 'notnull') {
+ $attributeSelect = $this->resourceConnection->getConnection()
+ ->select()
+ ->from(
+ [$tableAlias => $attribute->getBackendTable()],
+ $tableAlias . '.' . $entityIdField
+ )->where(
+ $this->resourceConnection->getConnection()->prepareSqlCondition(
+ $tableAlias . '.' . $attribute->getIdFieldName(),
+ ['eq' => $attribute->getAttributeId()]
+ )
+ )->where($tableAlias . '.value IS NOT NULL')
+ ->where($tableAlias . '.value != ?', '');
} else {
$attributeSelect = $this->resourceConnection->getConnection()
->select()
->from(
[$tableAlias => $attribute->getBackendTable()],
- $tableAlias . '.' . $attribute->getEntityIdField()
+ $tableAlias . '.' . $entityIdField
)->where(
$this->resourceConnection->getConnection()->prepareSqlCondition(
$tableAlias . '.' . $attribute->getIdFieldName(),
@@ -91,12 +112,14 @@ public function build(Filter $filter): string
);
}
+ $outerConditionType = $isNegative ? 'nin' : 'in';
+
return $this->resourceConnection
->getConnection()
->prepareSqlCondition(
- Collection::MAIN_TABLE_ALIAS . '.' . $attribute->getEntityIdField(),
+ Collection::MAIN_TABLE_ALIAS . '.' . $entityIdField,
[
- 'in' => $attributeSelect
+ $outerConditionType => $attributeSelect
]
);
}
@@ -129,6 +152,34 @@ private function mapConditionType(string $conditionType): string
return isset($conditionsMap[$conditionType]) ? $conditionsMap[$conditionType] : $conditionType;
}
+ /**
+ * Whether the filter condition type is a negative comparison.
+ *
+ * @param string $conditionType
+ * @return bool
+ */
+ private function isNegativeConditionType(string $conditionType): bool
+ {
+ return in_array($conditionType, ['neq', 'nin', 'nlike'], true);
+ }
+
+ /**
+ * Map a (possibly already-mapped) negative condition type to its positive counterpart.
+ *
+ * @param string $conditionType
+ * @return string
+ */
+ private function mapToPositiveConditionType(string $conditionType): string
+ {
+ $conditionsMap = [
+ 'neq' => 'eq',
+ 'nin' => 'in',
+ 'nlike' => 'like',
+ ];
+
+ return $conditionsMap[$conditionType] ?? $conditionType;
+ }
+
/**
* Wraps value with '%' if condition type is 'like' or 'not like'
*
@@ -140,7 +191,7 @@ private function mapConditionValue(string $conditionType, string $conditionValue
{
$conditionsMap = ['like', 'nlike'];
- if (in_array($conditionType, $conditionsMap)) {
+ if (in_array($conditionType, $conditionsMap, true)) {
$conditionValue = '%' . $conditionValue . '%';
}
diff --git a/app/code/Magento/CatalogRule/Model/Rule/Condition/ConditionsToSearchCriteriaMapper.php b/app/code/Magento/CatalogRule/Model/Rule/Condition/ConditionsToSearchCriteriaMapper.php
index 29534978f1e2c..7bc5bed7e2494 100644
--- a/app/code/Magento/CatalogRule/Model/Rule/Condition/ConditionsToSearchCriteriaMapper.php
+++ b/app/code/Magento/CatalogRule/Model/Rule/Condition/ConditionsToSearchCriteriaMapper.php
@@ -224,6 +224,8 @@ private function reverseSqlOperatorInFilter(Filter $filter)
'nlike' => 'like',
'in' => 'nin',
'nin' => 'in',
+ 'is_null' => 'notnull',
+ 'notnull' => 'is_null',
];
if (!array_key_exists($filter->getConditionType(), $operatorsMap)) {
@@ -299,7 +301,7 @@ private function mapRuleOperatorToSQLCondition(string $ruleOperator): string
'!{}' => 'nlike', // does not contains
'()' => 'in', // is one of
'!()' => 'nin', // is not one of
- '<=>' => 'is_null'
+ '<=>' => 'is_null',
];
if (!array_key_exists($ruleOperator, $operatorsMap)) {
diff --git a/app/code/Magento/CatalogRule/Model/Rule/Condition/Product.php b/app/code/Magento/CatalogRule/Model/Rule/Condition/Product.php
index 77153a516d272..223415ac1fbba 100644
--- a/app/code/Magento/CatalogRule/Model/Rule/Condition/Product.php
+++ b/app/code/Magento/CatalogRule/Model/Rule/Condition/Product.php
@@ -31,13 +31,13 @@ public function validate(\Magento\Framework\Model\AbstractModel $model)
$this->_setAttributeValue($model);
$attrValue = $model->getData($attrCode);
- if ($attrValue === null) {
- if ($this->getOperator() === '<=>') {
- $this->_restoreOldAttrValue($model, $oldAttrValue);
- return true;
- }
+ if ($this->isAttributeValueUndefined($attrValue)) {
+ // Missing/empty values match "is undefined" and negative operators (is not / does not contain / …).
+ // "Is defined" is expressed as a FALSE combine over "is undefined", not a separate operator.
+ $operator = $this->getOperator();
+ $matchesMissingValue = $operator === '<=>' || $this->isNegativeOperator($operator);
$this->_restoreOldAttrValue($model, $oldAttrValue);
- return false;
+ return $matchesMissingValue;
}
$result = $this->validateAttribute($attrValue);
@@ -131,4 +131,15 @@ protected function _prepareMultiselectValue($value, \Magento\Framework\Model\Abs
return $value;
}
+
+ /**
+ * Whether operator is a negative comparison that should match missing attribute values.
+ *
+ * @param string|null $operator
+ * @return bool
+ */
+ private function isNegativeOperator(?string $operator): bool
+ {
+ return in_array($operator, ['!=', '!{}', '!()'], true);
+ }
}
diff --git a/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleIsUndefinedConditionActionGroup.xml b/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleIsUndefinedConditionActionGroup.xml
new file mode 100644
index 0000000000000..09af9b9be7f1a
--- /dev/null
+++ b/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleIsUndefinedConditionActionGroup.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+ Add an "is undefined" product attribute condition on Catalog Price Rule Conditions tab.
+ Use defaultOperatorLabel "contains" for multiselect and "is" for select/boolean attributes.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleMultiselectConditionActionGroup.xml b/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleMultiselectConditionActionGroup.xml
new file mode 100644
index 0000000000000..97a22a1984879
--- /dev/null
+++ b/app/code/Magento/CatalogRule/Test/Mftf/ActionGroup/AdminFillCatalogRuleMultiselectConditionActionGroup.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+ Add a multiselect product attribute condition on Catalog Price Rule Conditions tab.
+ Default operator label is "contains"; select another operator and option value as needed.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectDoesNotContainTest.xml b/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectDoesNotContainTest.xml
new file mode 100644
index 0000000000000..4980bb74e44f2
--- /dev/null
+++ b/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectDoesNotContainTest.xml
@@ -0,0 +1,129 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 100.00
+
+
+
+ 100.00
+
+
+
+ 100.00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectIsUndefinedTest.xml b/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectIsUndefinedTest.xml
new file mode 100644
index 0000000000000..3c4595e187911
--- /dev/null
+++ b/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleMultiselectIsUndefinedTest.xml
@@ -0,0 +1,104 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 100.00
+
+
+
+ 100.00
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleNegativeCategoryConditionTest.xml b/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleNegativeCategoryConditionTest.xml
new file mode 100644
index 0000000000000..6481806121a18
--- /dev/null
+++ b/app/code/Magento/CatalogRule/Test/Mftf/Test/AdminApplyCatalogRuleNegativeCategoryConditionTest.xml
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/ConditionsToSearchCriteriaMapperTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/ConditionsToSearchCriteriaMapperTest.php
index 5f6f4a83c7c30..dd8f7d09c44e1 100644
--- a/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/ConditionsToSearchCriteriaMapperTest.php
+++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/ConditionsToSearchCriteriaMapperTest.php
@@ -782,6 +782,8 @@ public function testAllSqlOperatorReversals()
'nlike' => 'like',
'in' => 'nin',
'nin' => 'in',
+ 'is_null' => 'notnull',
+ 'notnull' => 'is_null',
];
foreach ($reversalMappings as $original => $reversed) {
diff --git a/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/ProductTest.php b/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/ProductTest.php
index 585dd328d173d..ba8150d725fa0 100644
--- a/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/ProductTest.php
+++ b/app/code/Magento/CatalogRule/Test/Unit/Model/Rule/Condition/ProductTest.php
@@ -199,13 +199,18 @@ public function testValidateWithDatetimeValue($attributeValue, $parsedValue, $ne
}
/**
+ * Missing attribute values must match negative operators and "is undefined".
+ *
+ * @param string $operator
+ * @param bool $expected
* @return void
*/
- public function testValidateWithNoValue(): void
+ #[DataProvider('validateWithNoValueDataProvider')]
+ public function testValidateWithNoValue(string $operator, bool $expected): void
{
$this->product->setData('attribute', 'color');
$this->product->setData('value_parsed', '1');
- $this->product->setData('operator', '!=');
+ $this->product->setData('operator', $operator);
$this->productModel->expects($this->atLeastOnce())
->method('getData')
@@ -217,7 +222,25 @@ public function testValidateWithNoValue(): void
$this->productModel->expects($this->any())
->method('getStoreId')
->willReturn('1');
- $this->assertFalse($this->product->validate($this->productModel));
+ $this->assertSame($expected, $this->product->validate($this->productModel));
+ }
+
+ /**
+ * @return array
+ */
+ public static function validateWithNoValueDataProvider(): array
+ {
+ return [
+ 'is not' => ['!=', true],
+ 'does not contain' => ['!{}', true],
+ 'is not one of' => ['!()', true],
+ 'is undefined' => ['<=>', true],
+ 'is' => ['==', false],
+ 'contains' => ['{}', false],
+ 'is one of' => ['()', false],
+ 'greater than' => ['>', false],
+ 'less than' => ['<', false],
+ ];
}
/**
diff --git a/app/code/Magento/Rule/Model/Condition/AbstractCondition.php b/app/code/Magento/Rule/Model/Condition/AbstractCondition.php
index e7645dad5b4b9..8a5375e6997fe 100644
--- a/app/code/Magento/Rule/Model/Condition/AbstractCondition.php
+++ b/app/code/Magento/Rule/Model/Condition/AbstractCondition.php
@@ -108,7 +108,8 @@ public function getDefaultOperatorInputByType()
'date' => ['==', '>=', '<='],
'select' => ['==', '!=', '<=>'],
'boolean' => ['==', '!=', '<=>'],
- 'multiselect' => ['{}', '!{}', '()', '!()'],
+ // Multiselect gains "is undefined" so empty values can be targeted (or inverted via a FALSE combine).
+ 'multiselect' => ['{}', '!{}', '()', '!()', '<=>'],
'grid' => ['()', '!()'],
];
$this->_arrayInputTypes = ['multiselect', 'grid'];
@@ -786,6 +787,11 @@ public function validateAttribute($validatedValue)
*/
$option = $this->getOperatorForValidate();
+ // "is undefined" does not use a condition value and must not depend on array-operator typing.
+ if ($option === '<=>') {
+ return $this->isAttributeValueUndefined($validatedValue);
+ }
+
// if operator requires array and it is not, or on opposite, return false
if ($this->isArrayOperatorType() xor is_array($value)) {
return false;
@@ -837,8 +843,10 @@ public function validateAttribute($validatedValue)
}
}
} elseif (is_array($value)) {
+ // Empty product values do not "contain" the needle. For !{} the final
if (!is_array($validatedValue) || empty($validatedValue)) {
- return false;
+ $result = false;
+ break;
}
$result = array_intersect($value, $validatedValue);
$result = !empty($result);
@@ -879,6 +887,24 @@ public function validateAttribute($validatedValue)
return $result;
}
+ /**
+ * Whether a product attribute value is considered undefined/empty for rule matching.
+ *
+ * @param mixed $validatedValue
+ * @return bool
+ */
+ protected function isAttributeValueUndefined($validatedValue): bool
+ {
+ if ($validatedValue === null || $validatedValue === '') {
+ return true;
+ }
+ if (is_array($validatedValue) && $validatedValue === []) {
+ return true;
+ }
+
+ return false;
+ }
+
/**
* Case and type insensitive comparison of values
*
diff --git a/app/code/Magento/Rule/Model/Condition/Sql/Builder.php b/app/code/Magento/Rule/Model/Condition/Sql/Builder.php
index a45d955dae98e..4935b38fd0c16 100644
--- a/app/code/Magento/Rule/Model/Condition/Sql/Builder.php
+++ b/app/code/Magento/Rule/Model/Condition/Sql/Builder.php
@@ -30,7 +30,7 @@ class Builder
*/
protected $_conditionOperatorMap = [
'==' => ':field = ?',
- '!=' => ':field <> ?',
+ '!=' => '(:field IS NULL OR :field = \'\' OR :field <> ?)',
'>=' => ':field >= ?',
'>=' => ':field >= ?',
'>' => ':field > ?',
@@ -40,9 +40,10 @@ class Builder
'<' => ':field < ?',
'<' => ':field < ?',
'{}' => ':field IN (?)',
- '!{}' => ':field NOT IN (?)',
+ '!{}' => '(:field IS NULL OR :field = \'\' OR :field NOT IN (?))',
'()' => ':field IN (?)',
- '!()' => ':field NOT IN (?)',
+ '!()' => '(:field IS NULL OR :field = \'\' OR :field NOT IN (?))',
+ '<=>' => '(:field IS NULL OR :field = \'\')',
];
/**
@@ -50,7 +51,7 @@ class Builder
*/
private $stringConditionOperatorMap = [
'{}' => ':field LIKE ?',
- '!{}' => ':field NOT LIKE ?',
+ '!{}' => '(:field IS NULL OR :field = \'\' OR :field NOT LIKE ?)',
];
/**
@@ -168,6 +169,7 @@ protected function _getMappedSqlCondition(
//operator 'contains {}' is mapped to 'IN()' query that cannot work with substrings
// adding mapping to 'LIKE %%'
+ $bindValue = $condition->getBindArgumentValue();
if ($condition->getInputType() === 'string'
&& in_array($conditionOperator, array_keys($this->stringConditionOperatorMap), true)
) {
@@ -176,15 +178,20 @@ protected function _getMappedSqlCondition(
(string)$this->_connection->quoteIdentifier($argument),
$this->stringConditionOperatorMap[$conditionOperator]
);
- $bindValue = $condition->getBindArgumentValue();
$expression = $value . $this->_connection->quoteInto($sql, "%$bindValue%");
+ } elseif ($conditionOperator === '<=>') {
+ $sql = str_replace(
+ ':field',
+ (string)$this->_connection->quoteIdentifier($argument),
+ $this->_conditionOperatorMap[$conditionOperator]
+ );
+ $expression = $value . $sql;
} else {
$sql = str_replace(
':field',
(string)$this->_connection->quoteIdentifier($argument),
$this->_conditionOperatorMap[$conditionOperator]
);
- $bindValue = $condition->getBindArgumentValue();
$expression = $value . $this->_connection->quoteInto($sql, $bindValue);
}
// values for multiselect attributes can be saved in comma-separated format
diff --git a/app/code/Magento/Rule/Test/Unit/Model/Condition/AbstractConditionTest.php b/app/code/Magento/Rule/Test/Unit/Model/Condition/AbstractConditionTest.php
index 213629af73b5c..5fe1749eed6a5 100644
--- a/app/code/Magento/Rule/Test/Unit/Model/Condition/AbstractConditionTest.php
+++ b/app/code/Magento/Rule/Test/Unit/Model/Condition/AbstractConditionTest.php
@@ -99,6 +99,8 @@ public static function validateAttributeDataProvider()
[1, '>=', 0, false],
[0, '<', [1], false],
+ // Without grid/multiselect input type, !{} + array needle hits isArrayOperatorType mismatch.
+ // Empty product values with grid input type are covered in validateAttributeArrayInputTypeDataProvider.
[[1], '!{}', [], false],
[[1], '!{}', [1], false],
[[1], '!{}', [0], false],
@@ -186,10 +188,17 @@ public static function validateAttributeArrayInputTypeDataProvider()
[1, '{}', 1, false, 'grid'],
[1, '!{}', [1, 2, 3], false, 'grid'],
[1, '!{}', [], false, 'grid'],
- [[1], '!{}', [], false, 'grid'],
+ [[1], '!{}', [], true, 'grid'],
[[1], '{}', null, false, 'grid'],
[null, '{}', null, true, 'input'],
[null, '!{}', null, false, 'input'],
+
+ // is undefined (condition needle is ignored)
+ [null, '<=>', null, true, 'select'],
+ [null, '<=>', '', true, 'select'],
+ [null, '<=>', 1, false, 'select'],
+ [null, '<=>', [], true, 'multiselect'],
+ [null, '<=>', [1], false, 'multiselect'],
[null, '{}', [1], false, 'input'],
[[1, 2, 3], '()', 1, true, 'select'],
diff --git a/dev/tests/integration/testsuite/Magento/CatalogRule/Model/Indexer/NegativeConditionRulePriceTest.php b/dev/tests/integration/testsuite/Magento/CatalogRule/Model/Indexer/NegativeConditionRulePriceTest.php
new file mode 100644
index 0000000000000..19253c6d603fc
--- /dev/null
+++ b/dev/tests/integration/testsuite/Magento/CatalogRule/Model/Indexer/NegativeConditionRulePriceTest.php
@@ -0,0 +1,407 @@
+objectManager = Bootstrap::getObjectManager();
+ $this->fixtures = DataFixtureStorageManager::getStorage();
+ $this->resourceRule = $this->objectManager->get(RuleResource::class);
+ $this->indexBuilder = $this->objectManager->get(IndexBuilder::class);
+
+ $this->objectManager->get(Product\ProductRuleProcessor::class)
+ ->getIndexer()
+ ->setScheduled(false);
+ $this->objectManager->get(Rule\RuleProductProcessor::class)
+ ->getIndexer()
+ ->setScheduled(false);
+ }
+
+ /**
+ * Multiselect "does not contain": empty product gets discount; product with option does not.
+ */
+ #[
+ DataFixture(
+ MultiselectAttributeFixture::class,
+ [
+ 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID,
+ 'source_model' => null,
+ 'backend_model' => ArrayBackend::class,
+ 'is_used_for_promo_rules' => true,
+ 'attribute_model' => Attribute::class,
+ 'options' => ['option_a', 'option_b'],
+ ],
+ 'attr'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'e2e-ms-empty',
+ 'price' => self::PRODUCT_PRICE,
+ ],
+ 'product_empty'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'e2e-ms-has-a',
+ 'price' => self::PRODUCT_PRICE,
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'product_excluded'
+ ),
+ DataFixture(
+ CatalogRuleFixture::class,
+ [
+ 'name' => 'E2E multiselect does not contain option_a',
+ 'is_active' => 1,
+ 'website_ids' => [self::WEBSITE_ID],
+ 'customer_group_ids' => [self::CUSTOMER_GROUP_ID],
+ 'simple_action' => 'by_percent',
+ 'discount_amount' => self::DISCOUNT_PERCENT,
+ 'stop_rules_processing' => false,
+ 'conditions' => [
+ [
+ 'attribute' => '$attr.attribute_code$',
+ 'operator' => '!{}',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'rule'
+ )
+ ]
+ public function testMultiselectDoesNotContainAppliesRulePriceToEmptyProduct(): void
+ {
+ $this->assertNegativeConditionRulePrices();
+ }
+
+ /**
+ * Select "is not": empty product gets discount; product with excluded option does not.
+ */
+ #[
+ DataFixture(
+ SelectAttributeFixture::class,
+ [
+ 'is_used_for_promo_rules' => true,
+ 'options' => ['option_a', 'option_b'],
+ ],
+ 'attr'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'e2e-sel-empty',
+ 'price' => self::PRODUCT_PRICE,
+ ],
+ 'product_empty'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'e2e-sel-has-a',
+ 'price' => self::PRODUCT_PRICE,
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'product_excluded'
+ ),
+ DataFixture(
+ CatalogRuleFixture::class,
+ [
+ 'name' => 'E2E select is not option_a',
+ 'is_active' => 1,
+ 'website_ids' => [self::WEBSITE_ID],
+ 'customer_group_ids' => [self::CUSTOMER_GROUP_ID],
+ 'simple_action' => 'by_percent',
+ 'discount_amount' => self::DISCOUNT_PERCENT,
+ 'stop_rules_processing' => false,
+ 'conditions' => [
+ [
+ 'attribute' => '$attr.attribute_code$',
+ 'operator' => '!=',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'rule'
+ )
+ ]
+ public function testSelectIsNotAppliesRulePriceToEmptyProduct(): void
+ {
+ $this->assertNegativeConditionRulePrices();
+ }
+
+ /**
+ * Category "is not": product without that category gets discount; product in it does not.
+ */
+ #[
+ DataFixture(CategoryFixture::class, as: 'category'),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'e2e-cat-empty',
+ 'price' => self::PRODUCT_PRICE,
+ ],
+ 'product_empty'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'e2e-cat-in',
+ 'price' => self::PRODUCT_PRICE,
+ 'category_ids' => ['$category.id$'],
+ ],
+ 'product_excluded'
+ ),
+ DataFixture(
+ CatalogRuleFixture::class,
+ [
+ 'name' => 'E2E category is not fixture category',
+ 'is_active' => 1,
+ 'website_ids' => [self::WEBSITE_ID],
+ 'customer_group_ids' => [self::CUSTOMER_GROUP_ID],
+ 'simple_action' => 'by_percent',
+ 'discount_amount' => self::DISCOUNT_PERCENT,
+ 'stop_rules_processing' => false,
+ 'conditions' => [
+ [
+ 'attribute' => 'category_ids',
+ 'operator' => '!=',
+ 'value' => '$category.id$',
+ ],
+ ],
+ ],
+ 'rule'
+ )
+ ]
+ public function testCategoryIsNotAppliesRulePriceToProductWithoutCategory(): void
+ {
+ $this->assertNegativeConditionRulePrices();
+ }
+
+ #[
+ DataFixture(
+ MultiselectAttributeFixture::class,
+ [
+ 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID,
+ 'source_model' => null,
+ 'backend_model' => ArrayBackend::class,
+ 'is_used_for_promo_rules' => true,
+ 'attribute_model' => Attribute::class,
+ 'options' => ['option_a', 'option_b'],
+ ],
+ 'attr'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'e2e-ms-undef-empty',
+ 'price' => self::PRODUCT_PRICE,
+ ],
+ 'product_empty'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'e2e-ms-undef-has',
+ 'price' => self::PRODUCT_PRICE,
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'product_excluded'
+ ),
+ DataFixture(
+ CatalogRuleFixture::class,
+ [
+ 'name' => 'E2E multiselect is undefined',
+ 'is_active' => 1,
+ 'website_ids' => [self::WEBSITE_ID],
+ 'customer_group_ids' => [self::CUSTOMER_GROUP_ID],
+ 'simple_action' => 'by_percent',
+ 'discount_amount' => self::DISCOUNT_PERCENT,
+ 'stop_rules_processing' => false,
+ 'conditions' => [
+ [
+ 'attribute' => '$attr.attribute_code$',
+ 'operator' => '<=>',
+ 'value' => '',
+ ],
+ ],
+ ],
+ 'rule'
+ )
+ ]
+ public function testMultiselectIsUndefinedAppliesRulePriceToEmptyProduct(): void
+ {
+ $this->assertNegativeConditionRulePrices();
+ }
+
+ #[
+ DataFixture(
+ MultiselectAttributeFixture::class,
+ [
+ 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID,
+ 'source_model' => null,
+ 'backend_model' => ArrayBackend::class,
+ 'is_used_for_promo_rules' => true,
+ 'attribute_model' => Attribute::class,
+ 'options' => ['option_a', 'option_b'],
+ ],
+ 'attr'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'e2e-ms-nin-empty',
+ 'price' => self::PRODUCT_PRICE,
+ ],
+ 'product_empty'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'e2e-ms-nin-has-a',
+ 'price' => self::PRODUCT_PRICE,
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'product_excluded'
+ ),
+ DataFixture(
+ CatalogRuleFixture::class,
+ [
+ 'name' => 'E2E multiselect is not one of option_a',
+ 'is_active' => 1,
+ 'website_ids' => [self::WEBSITE_ID],
+ 'customer_group_ids' => [self::CUSTOMER_GROUP_ID],
+ 'simple_action' => 'by_percent',
+ 'discount_amount' => self::DISCOUNT_PERCENT,
+ 'stop_rules_processing' => false,
+ 'conditions' => [
+ [
+ 'attribute' => '$attr.attribute_code$',
+ 'operator' => '!()',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'rule'
+ )
+ ]
+ public function testMultiselectIsNotOneOfAppliesRulePriceToEmptyProduct(): void
+ {
+ $this->assertNegativeConditionRulePrices();
+ }
+
+ private function assertNegativeConditionRulePrices(): void
+ {
+ $emptyProductId = (int)$this->fixtures->get('product_empty')->getId();
+ $excludedProductId = (int)$this->fixtures->get('product_excluded')->getId();
+
+ $this->indexBuilder->reindexByIds([$emptyProductId, $excludedProductId]);
+
+ $date = new \DateTime();
+ $emptyRulePrice = $this->resourceRule->getRulePrice(
+ $date,
+ self::WEBSITE_ID,
+ self::CUSTOMER_GROUP_ID,
+ $emptyProductId
+ );
+ $excludedRulePrice = $this->resourceRule->getRulePrice(
+ $date,
+ self::WEBSITE_ID,
+ self::CUSTOMER_GROUP_ID,
+ $excludedProductId
+ );
+
+ $this->assertEqualsWithDelta(
+ self::EXPECTED_RULE_PRICE,
+ (float)$emptyRulePrice,
+ 0.0001,
+ 'Product without the excluded value must receive the catalog rule price after reindex'
+ );
+ $this->assertFalse(
+ $excludedRulePrice,
+ 'Product with the excluded value must not receive a catalog rule price'
+ );
+ }
+}
diff --git a/dev/tests/integration/testsuite/Magento/CatalogRule/Model/ResourceModel/Product/NegativeEavConditionsToCollectionTest.php b/dev/tests/integration/testsuite/Magento/CatalogRule/Model/ResourceModel/Product/NegativeEavConditionsToCollectionTest.php
new file mode 100644
index 0000000000000..a8384c3eb0dc3
--- /dev/null
+++ b/dev/tests/integration/testsuite/Magento/CatalogRule/Model/ResourceModel/Product/NegativeEavConditionsToCollectionTest.php
@@ -0,0 +1,339 @@
+objectManager = Bootstrap::getObjectManager();
+ $this->fixtures = DataFixtureStorageManager::getStorage();
+ $this->conditionsToCollectionApplier = $this->objectManager->get(ConditionsToCollectionApplier::class);
+ $this->productCollectionFactory = $this->objectManager->get(ProductCollectionFactory::class);
+ $this->combineConditionFactory = $this->objectManager->get(CombineFactory::class);
+ $this->productConditionFactory = $this->objectManager->get(ProductConditionFactory::class);
+ }
+
+ #[
+ DataFixture(
+ MultiselectAttributeFixture::class,
+ [
+ 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID,
+ 'source_model' => null,
+ 'backend_model' => ArrayBackend::class,
+ 'is_used_for_promo_rules' => true,
+ 'attribute_model' => Attribute::class,
+ 'options' => ['option_a', 'option_b'],
+ ],
+ 'attr'
+ ),
+ DataFixture(ProductFixture::class, ['sku' => 'sql-ms-empty'], 'product_empty'),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'sql-ms-a',
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'product_a'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'sql-ms-b',
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_b$',
+ ],
+ ],
+ ],
+ 'product_b'
+ )
+ ]
+ public function testMultiselectDoesNotContainIncludesEmptyProductInCollection(): void
+ {
+ $skus = $this->getFilteredSkus(
+ (string)$this->fixtures->get('attr')->getAttributeCode(),
+ '!{}',
+ (string)$this->fixtures->get('attr')->getData('option_a'),
+ ['sql-ms-empty', 'sql-ms-a', 'sql-ms-b']
+ );
+
+ $this->assertContains('sql-ms-empty', $skus);
+ $this->assertContains('sql-ms-b', $skus);
+ $this->assertNotContains('sql-ms-a', $skus);
+ }
+
+ #[
+ DataFixture(
+ MultiselectAttributeFixture::class,
+ [
+ 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID,
+ 'source_model' => null,
+ 'backend_model' => ArrayBackend::class,
+ 'is_used_for_promo_rules' => true,
+ 'attribute_model' => Attribute::class,
+ 'options' => ['option_a', 'option_b'],
+ ],
+ 'attr'
+ ),
+ DataFixture(ProductFixture::class, ['sku' => 'sql-ms-empty'], 'product_empty'),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'sql-ms-a',
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'product_a'
+ )
+ ]
+ public function testMultiselectIsUndefinedIncludesOnlyEmptyProductInCollection(): void
+ {
+ $skus = $this->getFilteredSkus(
+ (string)$this->fixtures->get('attr')->getAttributeCode(),
+ '<=>',
+ '',
+ ['sql-ms-empty', 'sql-ms-a']
+ );
+
+ $this->assertContains('sql-ms-empty', $skus);
+ $this->assertNotContains('sql-ms-a', $skus);
+ }
+
+ #[
+ DataFixture(
+ SelectAttributeFixture::class,
+ [
+ 'is_used_for_promo_rules' => true,
+ 'options' => ['option_a', 'option_b'],
+ ],
+ 'attr'
+ ),
+ DataFixture(ProductFixture::class, ['sku' => 'sql-sel-empty'], 'product_empty'),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'sql-sel-a',
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'product_a'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'sql-sel-b',
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_b$',
+ ],
+ ],
+ ],
+ 'product_b'
+ )
+ ]
+ public function testSelectIsNotIncludesEmptyProductInCollection(): void
+ {
+ $skus = $this->getFilteredSkus(
+ (string)$this->fixtures->get('attr')->getAttributeCode(),
+ '!=',
+ (string)$this->fixtures->get('attr')->getData('option_a'),
+ ['sql-sel-empty', 'sql-sel-a', 'sql-sel-b']
+ );
+
+ $this->assertContains('sql-sel-empty', $skus);
+ $this->assertContains('sql-sel-b', $skus);
+ $this->assertNotContains('sql-sel-a', $skus);
+ }
+
+ #[
+ DataFixture(
+ MultiselectAttributeFixture::class,
+ [
+ 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID,
+ 'source_model' => null,
+ 'backend_model' => ArrayBackend::class,
+ 'is_used_for_promo_rules' => true,
+ 'attribute_model' => Attribute::class,
+ 'options' => ['option_a', 'option_b'],
+ ],
+ 'attr'
+ ),
+ DataFixture(ProductFixture::class, ['sku' => 'sql-false-empty'], 'product_empty'),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'sql-false-a',
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'product_a'
+ )
+ ]
+ public function testFalseIsUndefinedExcludesEmptyProductInCollection(): void
+ {
+ $attributeCode = (string)$this->fixtures->get('attr')->getAttributeCode();
+
+ /** @var ProductCondition $undefinedCondition */
+ $undefinedCondition = $this->productConditionFactory->create();
+ $undefinedCondition->setType(ProductCondition::class);
+ $undefinedCondition->setAttribute($attributeCode);
+ $undefinedCondition->setOperator('<=>');
+ $undefinedCondition->setValue('');
+
+ /** @var Combine $falseCombine */
+ $falseCombine = $this->combineConditionFactory->create();
+ $falseCombine->setType(Combine::class);
+ $falseCombine->setAggregator('all');
+ $falseCombine->setValue(0);
+ $falseCombine->setConditions([$undefinedCondition]);
+
+ /** @var Combine $root */
+ $root = $this->combineConditionFactory->create();
+ $root->setType(Combine::class);
+ $root->setAggregator('all');
+ $root->setValue(1);
+ $root->setConditions([$falseCombine]);
+
+ $skus = $this->filterSkusByCondition($root, ['sql-false-empty', 'sql-false-a']);
+
+ $this->assertNotContains('sql-false-empty', $skus);
+ $this->assertContains('sql-false-a', $skus);
+ }
+
+ /**
+ * @param string $attributeCode
+ * @param string $operator
+ * @param string $value
+ * @param string[] $candidateSkus
+ * @return string[]
+ */
+ private function getFilteredSkus(
+ string $attributeCode,
+ string $operator,
+ string $value,
+ array $candidateSkus
+ ): array {
+ /** @var ProductCondition $condition */
+ $condition = $this->productConditionFactory->create();
+ $condition->setType(ProductCondition::class);
+ $condition->setAttribute($attributeCode);
+ $condition->setOperator($operator);
+ $condition->setValue($value);
+
+ /** @var Combine $combine */
+ $combine = $this->combineConditionFactory->create();
+ $combine->setType(Combine::class);
+ $combine->setAggregator('all');
+ $combine->setValue(1);
+ $combine->setConditions([$condition]);
+
+ return $this->filterSkusByCondition($combine, $candidateSkus);
+ }
+
+ /**
+ * @param Combine $condition
+ * @param string[] $candidateSkus
+ * @return string[]
+ */
+ private function filterSkusByCondition(Combine $condition, array $candidateSkus): array
+ {
+ $collection = $this->productCollectionFactory->create();
+ $collection->addAttributeToSelect('sku');
+ $collection->addFieldToFilter('sku', ['in' => $candidateSkus]);
+
+ $filtered = $this->conditionsToCollectionApplier->applyConditionsToCollection($condition, $collection);
+
+ return array_map(
+ static function (Product $product): string {
+ return (string)$product->getSku();
+ },
+ array_values($filtered->getItems())
+ );
+ }
+}
diff --git a/dev/tests/integration/testsuite/Magento/CatalogRule/Model/Rule/DefinedUndefinedConditionsTest.php b/dev/tests/integration/testsuite/Magento/CatalogRule/Model/Rule/DefinedUndefinedConditionsTest.php
new file mode 100644
index 0000000000000..b6b43c7117bea
--- /dev/null
+++ b/dev/tests/integration/testsuite/Magento/CatalogRule/Model/Rule/DefinedUndefinedConditionsTest.php
@@ -0,0 +1,280 @@
+objectManager = Bootstrap::getObjectManager();
+ $this->fixtures = DataFixtureStorageManager::getStorage();
+ }
+
+ /**
+ * Multiselect "is undefined" matches products without a value.
+ */
+ #[
+ DataFixture(
+ MultiselectAttributeFixture::class,
+ [
+ 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID,
+ 'source_model' => null,
+ 'backend_model' => ArrayBackend::class,
+ 'is_used_for_promo_rules' => true,
+ 'attribute_model' => Attribute::class,
+ 'options' => ['option_a', 'option_b'],
+ ],
+ 'attr'
+ ),
+ DataFixture(ProductFixture::class, ['sku' => 'undef-ms-empty'], 'product_empty'),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'undef-ms-has',
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'product_defined'
+ ),
+ DataFixture(
+ CatalogRuleFixture::class,
+ [
+ 'name' => 'Multiselect is undefined',
+ 'is_active' => 1,
+ 'website_ids' => [1],
+ 'customer_group_ids' => [0, 1],
+ 'simple_action' => 'by_percent',
+ 'discount_amount' => 10,
+ 'conditions' => [
+ [
+ 'attribute' => '$attr.attribute_code$',
+ 'operator' => '<=>',
+ 'value' => '',
+ ],
+ ],
+ ],
+ 'rule'
+ )
+ ]
+ public function testMultiselectIsUndefinedMatchesEmptyProductOnly(): void
+ {
+ $matchingIds = $this->getMatchingProductIds('rule');
+ $this->assertProductMatches($matchingIds, 'product_empty');
+ $this->assertProductDoesNotMatch($matchingIds, 'product_defined');
+ }
+
+ #[
+ DataFixture(
+ MultiselectAttributeFixture::class,
+ [
+ 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID,
+ 'source_model' => null,
+ 'backend_model' => ArrayBackend::class,
+ 'is_used_for_promo_rules' => true,
+ 'attribute_model' => Attribute::class,
+ 'options' => ['option_a', 'option_b'],
+ ],
+ 'attr'
+ ),
+ DataFixture(ProductFixture::class, ['sku' => 'combo-ms-empty'], 'product_empty'),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'combo-ms-a',
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'product_a'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'combo-ms-b',
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_b$',
+ ],
+ ],
+ ],
+ 'product_b'
+ )
+ ]
+ public function testIsNotWithFalseIsUndefinedExcludesEmptyProducts(): void
+ {
+ $attr = $this->fixtures->get('attr');
+ $attributeCode = $attr->getAttributeCode();
+ $optionA = $attr->getData('option_a');
+
+ /** @var Rule $rule */
+ $rule = $this->objectManager->get(\Magento\CatalogRule\Model\RuleFactory::class)->create();
+ $rule->loadPost([
+ 'name' => 'Is not A and not undefined',
+ 'is_active' => '1',
+ 'stop_rules_processing' => 0,
+ 'website_ids' => [1],
+ 'customer_group_ids' => [0, 1],
+ 'discount_amount' => 10,
+ 'simple_action' => 'by_percent',
+ 'from_date' => '',
+ 'to_date' => '',
+ 'sort_order' => 0,
+ 'conditions' => [
+ '1' => [
+ 'type' => Combine::class,
+ 'aggregator' => 'all',
+ 'value' => '1',
+ 'new_child' => '',
+ ],
+ '1--1' => [
+ 'type' => ProductCondition::class,
+ 'attribute' => $attributeCode,
+ 'operator' => '!()',
+ 'value' => $optionA,
+ ],
+ '1--2' => [
+ 'type' => Combine::class,
+ 'aggregator' => 'all',
+ 'value' => '0',
+ 'new_child' => '',
+ ],
+ '1--2--1' => [
+ 'type' => ProductCondition::class,
+ 'attribute' => $attributeCode,
+ 'operator' => '<=>',
+ 'value' => '',
+ ],
+ ],
+ ]);
+ $this->objectManager->get(\Magento\CatalogRule\Api\CatalogRuleRepositoryInterface::class)->save($rule);
+
+ $productIds = [
+ (int)$this->fixtures->get('product_empty')->getId(),
+ (int)$this->fixtures->get('product_a')->getId(),
+ (int)$this->fixtures->get('product_b')->getId(),
+ ];
+ $rule->setProductsFilter($productIds);
+ $matchingIds = $rule->getMatchingProductIds();
+
+ $this->assertProductDoesNotMatch(
+ $matchingIds,
+ 'product_empty',
+ 'Empty products must be excluded when is undefined is inverted via a FALSE combine'
+ );
+ $this->assertProductDoesNotMatch($matchingIds, 'product_a');
+ $this->assertProductMatches($matchingIds, 'product_b');
+ }
+
+ /**
+ * @param string $ruleFixtureName
+ * @return array>
+ */
+ private function getMatchingProductIds(string $ruleFixtureName): array
+ {
+ $ruleData = $this->fixtures->get($ruleFixtureName);
+ /** @var Rule $rule */
+ $rule = $this->objectManager->create(Rule::class);
+ $rule->load($ruleData->getId());
+ $this->assertNotEmpty($rule->getId());
+
+ $productIds = [];
+ foreach (['product_empty', 'product_defined', 'product_a', 'product_b'] as $name) {
+ $product = $this->fixtures->get($name);
+ if ($product !== null) {
+ $productIds[] = (int)$product->getId();
+ }
+ }
+ $rule->setProductsFilter($productIds);
+
+ return $rule->getMatchingProductIds();
+ }
+
+ /**
+ * @param array> $matchingIds
+ * @param string $productFixtureName
+ * @param string $message
+ */
+ private function assertProductMatches(
+ array $matchingIds,
+ string $productFixtureName,
+ string $message = ''
+ ): void {
+ $productId = (int)$this->fixtures->get($productFixtureName)->getId();
+ $this->assertArrayHasKey($productId, $matchingIds, $message ?: "Product {$productFixtureName} should match");
+ $this->assertNotEmpty(
+ array_filter($matchingIds[$productId]),
+ $message ?: "Product {$productFixtureName} should match for a website"
+ );
+ }
+
+ /**
+ * @param array> $matchingIds
+ * @param string $productFixtureName
+ * @param string $message
+ */
+ private function assertProductDoesNotMatch(
+ array $matchingIds,
+ string $productFixtureName,
+ string $message = ''
+ ): void {
+ $productId = (int)$this->fixtures->get($productFixtureName)->getId();
+ if (!isset($matchingIds[$productId])) {
+ $this->assertTrue(true);
+ return;
+ }
+ $this->assertEmpty(
+ array_filter($matchingIds[$productId]),
+ $message ?: "Product {$productFixtureName} should not match"
+ );
+ }
+}
diff --git a/dev/tests/integration/testsuite/Magento/CatalogRule/Model/Rule/MatchingProductIdsNegativeConditionsTest.php b/dev/tests/integration/testsuite/Magento/CatalogRule/Model/Rule/MatchingProductIdsNegativeConditionsTest.php
new file mode 100644
index 0000000000000..c81c643e8b385
--- /dev/null
+++ b/dev/tests/integration/testsuite/Magento/CatalogRule/Model/Rule/MatchingProductIdsNegativeConditionsTest.php
@@ -0,0 +1,556 @@
+objectManager = Bootstrap::getObjectManager();
+ $this->fixtures = DataFixtureStorageManager::getStorage();
+ }
+
+ /**
+ * Multiselect "does not contain" must include products that never had the attribute set.
+ */
+ #[
+ DataFixture(
+ MultiselectAttributeFixture::class,
+ [
+ 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID,
+ 'source_model' => null,
+ 'backend_model' => ArrayBackend::class,
+ 'is_used_for_promo_rules' => true,
+ 'attribute_model' => Attribute::class,
+ 'options' => ['option_a', 'option_b'],
+ ],
+ 'attr'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'ms-no-value',
+ 'custom_attributes' => [
+ // attribute intentionally unset — no EAV row
+ ],
+ ],
+ 'product_no_value'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'ms-has-a',
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'product_has_a'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'ms-has-b',
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_b$',
+ ],
+ ],
+ ],
+ 'product_has_b'
+ ),
+ DataFixture(
+ CatalogRuleFixture::class,
+ [
+ 'name' => 'Multiselect does not contain option_a',
+ 'is_active' => 1,
+ 'website_ids' => [1],
+ 'customer_group_ids' => [0, 1],
+ 'simple_action' => 'by_percent',
+ 'discount_amount' => 10,
+ 'conditions' => [
+ [
+ 'attribute' => '$attr.attribute_code$',
+ 'operator' => '!{}',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'rule'
+ )
+ ]
+ public function testMultiselectDoesNotContainIncludesProductsWithoutValue(): void
+ {
+ $matchingIds = $this->getMatchingProductIds('rule');
+
+ $this->assertProductMatches(
+ $matchingIds,
+ 'product_no_value',
+ 'Product without multiselect value must match "does not contain"'
+ );
+ $this->assertProductMatches(
+ $matchingIds,
+ 'product_has_b',
+ 'Product with a different multiselect option must match "does not contain"'
+ );
+ $this->assertProductDoesNotMatch(
+ $matchingIds,
+ 'product_has_a',
+ 'Product with the excluded multiselect option must not match "does not contain"'
+ );
+ }
+
+ /**
+ * Multiselect "is not one of" must include products that never had the attribute set.
+ */
+ #[
+ DataFixture(
+ MultiselectAttributeFixture::class,
+ [
+ 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID,
+ 'source_model' => null,
+ 'backend_model' => ArrayBackend::class,
+ 'is_used_for_promo_rules' => true,
+ 'attribute_model' => Attribute::class,
+ 'options' => ['option_a', 'option_b'],
+ ],
+ 'attr'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ ['sku' => 'ms2-no-value'],
+ 'product_no_value'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'ms2-has-a',
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'product_has_a'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'ms2-has-b',
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_b$',
+ ],
+ ],
+ ],
+ 'product_has_b'
+ ),
+ DataFixture(
+ CatalogRuleFixture::class,
+ [
+ 'name' => 'Multiselect is not one of option_a',
+ 'is_active' => 1,
+ 'website_ids' => [1],
+ 'customer_group_ids' => [0, 1],
+ 'simple_action' => 'by_percent',
+ 'discount_amount' => 10,
+ 'conditions' => [
+ [
+ 'attribute' => '$attr.attribute_code$',
+ 'operator' => '!()',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'rule'
+ )
+ ]
+ public function testMultiselectIsNotOneOfIncludesProductsWithoutValue(): void
+ {
+ $matchingIds = $this->getMatchingProductIds('rule');
+
+ $this->assertProductMatches(
+ $matchingIds,
+ 'product_no_value',
+ 'Product without multiselect value must match "is not one of"'
+ );
+ $this->assertProductMatches(
+ $matchingIds,
+ 'product_has_b',
+ 'Product with a different multiselect option must match "is not one of"'
+ );
+ $this->assertProductDoesNotMatch(
+ $matchingIds,
+ 'product_has_a',
+ 'Product with the excluded multiselect option must not match "is not one of"'
+ );
+ }
+
+ /**
+ * Dropdown "is not" must include products that never had the attribute set.
+ */
+ #[
+ DataFixture(
+ SelectAttributeFixture::class,
+ [
+ 'is_used_for_promo_rules' => true,
+ 'options' => ['option_a', 'option_b'],
+ ],
+ 'attr'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ ['sku' => 'sel-no-value'],
+ 'product_no_value'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'sel-has-a',
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'product_has_a'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'sel-has-b',
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_b$',
+ ],
+ ],
+ ],
+ 'product_has_b'
+ ),
+ DataFixture(
+ CatalogRuleFixture::class,
+ [
+ 'name' => 'Select is not option_a',
+ 'is_active' => 1,
+ 'website_ids' => [1],
+ 'customer_group_ids' => [0, 1],
+ 'simple_action' => 'by_percent',
+ 'discount_amount' => 10,
+ 'conditions' => [
+ [
+ 'attribute' => '$attr.attribute_code$',
+ 'operator' => '!=',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'rule'
+ )
+ ]
+ public function testSelectIsNotIncludesProductsWithoutValue(): void
+ {
+ $matchingIds = $this->getMatchingProductIds('rule');
+
+ $this->assertProductMatches(
+ $matchingIds,
+ 'product_no_value',
+ 'Product without select value must match "is not"'
+ );
+ $this->assertProductMatches(
+ $matchingIds,
+ 'product_has_b',
+ 'Product with a different select option must match "is not"'
+ );
+ $this->assertProductDoesNotMatch(
+ $matchingIds,
+ 'product_has_a',
+ 'Product with the excluded select option must not match "is not"'
+ );
+ }
+
+ /**
+ * Category "is not one of" must include products that are not assigned to that category,
+ * including products with no category assignment at all.
+ */
+ #[
+ DataFixture(CategoryFixture::class, as: 'category'),
+ DataFixture(
+ ProductFixture::class,
+ ['sku' => 'cat-no-category'],
+ 'product_no_category'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'cat-in-category',
+ 'category_ids' => ['$category.id$'],
+ ],
+ 'product_in_category'
+ ),
+ DataFixture(
+ CatalogRuleFixture::class,
+ [
+ 'name' => 'Category is not one of fixture category',
+ 'is_active' => 1,
+ 'website_ids' => [1],
+ 'customer_group_ids' => [0, 1],
+ 'simple_action' => 'by_percent',
+ 'discount_amount' => 10,
+ 'conditions' => [
+ [
+ 'attribute' => 'category_ids',
+ 'operator' => '!()',
+ 'value' => '$category.id$',
+ ],
+ ],
+ ],
+ 'rule'
+ )
+ ]
+ public function testCategoryIsNotOneOfIncludesProductsWithoutCategory(): void
+ {
+ $matchingIds = $this->getMatchingProductIds('rule');
+
+ $this->assertProductMatches(
+ $matchingIds,
+ 'product_no_category',
+ 'Product without category assignment must match "category is not one of"'
+ );
+ $this->assertProductDoesNotMatch(
+ $matchingIds,
+ 'product_in_category',
+ 'Product assigned to the excluded category must not match'
+ );
+ }
+
+ /**
+ * Category "is not" must include products that are not assigned to that category,
+ * including products with no category assignment at all.
+ */
+ #[
+ DataFixture(CategoryFixture::class, as: 'category'),
+ DataFixture(
+ ProductFixture::class,
+ ['sku' => 'cat2-no-category'],
+ 'product_no_category'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'cat2-in-category',
+ 'category_ids' => ['$category.id$'],
+ ],
+ 'product_in_category'
+ ),
+ DataFixture(
+ CatalogRuleFixture::class,
+ [
+ 'name' => 'Category is not fixture category',
+ 'is_active' => 1,
+ 'website_ids' => [1],
+ 'customer_group_ids' => [0, 1],
+ 'simple_action' => 'by_percent',
+ 'discount_amount' => 10,
+ 'conditions' => [
+ [
+ 'attribute' => 'category_ids',
+ 'operator' => '!=',
+ 'value' => '$category.id$',
+ ],
+ ],
+ ],
+ 'rule'
+ )
+ ]
+ public function testCategoryIsNotIncludesProductsWithoutCategory(): void
+ {
+ $matchingIds = $this->getMatchingProductIds('rule');
+
+ $this->assertProductMatches(
+ $matchingIds,
+ 'product_no_category',
+ 'Product without category assignment must match "category is not"'
+ );
+ $this->assertProductDoesNotMatch(
+ $matchingIds,
+ 'product_in_category',
+ 'Product assigned to the excluded category must not match'
+ );
+ }
+
+ /**
+ * special_price "is not" / absence: product with no special_price must match a negative
+ * comparison that excludes a concrete special_price value (issue comment scenario).
+ */
+ #[
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'sp-no-special',
+ 'price' => 100,
+ // special_price left unset
+ ],
+ 'product_no_special'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'sp-has-special',
+ 'price' => 100,
+ 'special_price' => 25,
+ ],
+ 'product_has_special'
+ ),
+ DataFixture(
+ CatalogRuleFixture::class,
+ [
+ 'name' => 'special_price is not 25',
+ 'is_active' => 1,
+ 'website_ids' => [1],
+ 'customer_group_ids' => [0, 1],
+ 'simple_action' => 'by_percent',
+ 'discount_amount' => 10,
+ 'conditions' => [
+ [
+ 'attribute' => 'special_price',
+ 'operator' => '!=',
+ 'value' => '25',
+ ],
+ ],
+ ],
+ 'rule'
+ )
+ ]
+ public function testSpecialPriceIsNotIncludesProductsWithoutSpecialPrice(): void
+ {
+ $matchingIds = $this->getMatchingProductIds('rule');
+
+ $this->assertProductMatches(
+ $matchingIds,
+ 'product_no_special',
+ 'Product without special_price must match "special_price is not 25"'
+ );
+ $this->assertProductDoesNotMatch(
+ $matchingIds,
+ 'product_has_special',
+ 'Product with special_price = 25 must not match "special_price is not 25"'
+ );
+ }
+
+ /**
+ * Load rule and return matching product IDs keyed by product ID.
+ *
+ * @param string $ruleFixtureName
+ * @return array>
+ */
+ private function getMatchingProductIds(string $ruleFixtureName): array
+ {
+ $ruleData = $this->fixtures->get($ruleFixtureName);
+ /** @var Rule $rule */
+ $rule = $this->objectManager->create(Rule::class);
+ $rule->load($ruleData->getId());
+ $this->assertNotEmpty($rule->getId(), 'Catalog rule fixture must be saved');
+
+ // Restrict matching to fixtures under test so unrelated catalog products do not affect assertions.
+ $productIds = [];
+ foreach (['product_no_value', 'product_has_a', 'product_has_b', 'product_no_category',
+ 'product_in_category', 'product_no_special', 'product_has_special'] as $name) {
+ $product = $this->fixtures->get($name);
+ if ($product !== null) {
+ $productIds[] = (int)$product->getId();
+ }
+ }
+ $rule->setProductsFilter($productIds);
+
+ return $rule->getMatchingProductIds();
+ }
+
+ /**
+ * @param array> $matchingIds
+ * @param string $productFixtureName
+ * @param string $message
+ */
+ private function assertProductMatches(array $matchingIds, string $productFixtureName, string $message): void
+ {
+ $productId = (int)$this->fixtures->get($productFixtureName)->getId();
+ $this->assertArrayHasKey(
+ $productId,
+ $matchingIds,
+ $message . sprintf(' (product id %d missing from matching set)', $productId)
+ );
+ $this->assertNotEmpty(
+ array_filter($matchingIds[$productId]),
+ $message . sprintf(' (product id %d present but not matched for any website)', $productId)
+ );
+ }
+
+ /**
+ * @param array> $matchingIds
+ * @param string $productFixtureName
+ * @param string $message
+ */
+ private function assertProductDoesNotMatch(
+ array $matchingIds,
+ string $productFixtureName,
+ string $message
+ ): void {
+ $productId = (int)$this->fixtures->get($productFixtureName)->getId();
+ if (!isset($matchingIds[$productId])) {
+ $this->assertTrue(true);
+ return;
+ }
+ $this->assertEmpty(
+ array_filter($matchingIds[$productId]),
+ $message . sprintf(' (product id %d matched for websites: %s)', $productId, json_encode($matchingIds[$productId]))
+ );
+ }
+}
diff --git a/dev/tests/integration/testsuite/Magento/CatalogWidget/Block/Product/NegativeMultiselectConditionsTest.php b/dev/tests/integration/testsuite/Magento/CatalogWidget/Block/Product/NegativeMultiselectConditionsTest.php
new file mode 100644
index 0000000000000..5244a53595da5
--- /dev/null
+++ b/dev/tests/integration/testsuite/Magento/CatalogWidget/Block/Product/NegativeMultiselectConditionsTest.php
@@ -0,0 +1,196 @@
+objectManager = Bootstrap::getObjectManager();
+ $this->fixtures = DataFixtureStorageManager::getStorage();
+ $this->block = $this->objectManager->create(ProductsList::class);
+ }
+
+ #[
+ DataFixture(
+ MultiselectAttributeFixture::class,
+ [
+ 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID,
+ 'source_model' => null,
+ 'backend_model' => ArrayBackend::class,
+ 'is_used_for_promo_rules' => true,
+ 'attribute_model' => Attribute::class,
+ 'options' => ['option_a', 'option_b'],
+ ],
+ 'attr'
+ ),
+ DataFixture(ProductFixture::class, ['sku' => 'widget-ms-empty'], 'product_empty'),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'widget-ms-a',
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'product_a'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'widget-ms-b',
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_b$',
+ ],
+ ],
+ ],
+ 'product_b'
+ )
+ ]
+ public function testDoesNotContainIncludesProductsWithoutMultiselectValue(): void
+ {
+ $attributeCode = (string)$this->fixtures->get('attr')->getAttributeCode();
+ $optionA = (string)$this->fixtures->get('attr')->getData('option_a');
+
+ $skus = $this->getCollectionSkus([
+ '1' => [
+ 'type' => Combine::class,
+ 'aggregator' => 'all',
+ 'value' => '1',
+ 'new_child' => '',
+ ],
+ '1--1' => [
+ 'type' => WidgetProductCondition::class,
+ 'attribute' => $attributeCode,
+ 'operator' => '!{}',
+ 'value' => $optionA,
+ ],
+ ]);
+
+ $this->assertContains('widget-ms-empty', $skus);
+ $this->assertContains('widget-ms-b', $skus);
+ $this->assertNotContains('widget-ms-a', $skus);
+ }
+
+ #[
+ DataFixture(
+ MultiselectAttributeFixture::class,
+ [
+ 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID,
+ 'source_model' => null,
+ 'backend_model' => ArrayBackend::class,
+ 'is_used_for_promo_rules' => true,
+ 'attribute_model' => Attribute::class,
+ 'options' => ['option_a', 'option_b'],
+ ],
+ 'attr'
+ ),
+ DataFixture(ProductFixture::class, ['sku' => 'widget-undef-empty'], 'product_empty'),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'widget-undef-a',
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'product_a'
+ )
+ ]
+ public function testIsUndefinedIncludesOnlyProductsWithoutMultiselectValue(): void
+ {
+ $attributeCode = (string)$this->fixtures->get('attr')->getAttributeCode();
+
+ $skus = $this->getCollectionSkus([
+ '1' => [
+ 'type' => Combine::class,
+ 'aggregator' => 'all',
+ 'value' => '1',
+ 'new_child' => '',
+ ],
+ '1--1' => [
+ 'type' => WidgetProductCondition::class,
+ 'attribute' => $attributeCode,
+ 'operator' => '<=>',
+ 'value' => '',
+ ],
+ ]);
+
+ $this->assertContains('widget-undef-empty', $skus);
+ $this->assertNotContains('widget-undef-a', $skus);
+ }
+
+ /**
+ * @param array> $conditions
+ * @return string[]
+ */
+ private function getCollectionSkus(array $conditions): array
+ {
+ $candidateIds = [];
+ foreach (['product_empty', 'product_a', 'product_b'] as $fixtureName) {
+ $product = $this->fixtures->get($fixtureName);
+ if ($product !== null) {
+ $candidateIds[] = (int)$product->getId();
+ }
+ }
+
+ $this->block->setConditions($conditions);
+ $collection = $this->block->createCollection();
+ $collection->addFieldToFilter('entity_id', ['in' => $candidateIds]);
+ $collection->load();
+
+ return $collection->getColumnValues('sku');
+ }
+}
diff --git a/dev/tests/integration/testsuite/Magento/SalesRule/Model/Rule/Condition/NegativeProductAttributeConditionTest.php b/dev/tests/integration/testsuite/Magento/SalesRule/Model/Rule/Condition/NegativeProductAttributeConditionTest.php
new file mode 100644
index 0000000000000..e111840634886
--- /dev/null
+++ b/dev/tests/integration/testsuite/Magento/SalesRule/Model/Rule/Condition/NegativeProductAttributeConditionTest.php
@@ -0,0 +1,189 @@
+objectManager = Bootstrap::getObjectManager();
+ $this->fixtures = DataFixtureStorageManager::getStorage();
+ $this->productRepository = $this->objectManager->get(ProductRepositoryInterface::class);
+ }
+
+ #[
+ DataFixture(
+ MultiselectAttributeFixture::class,
+ [
+ 'entity_type_id' => CategorySetup::CATALOG_PRODUCT_ENTITY_TYPE_ID,
+ 'source_model' => null,
+ 'backend_model' => ArrayBackend::class,
+ 'is_used_for_promo_rules' => true,
+ 'attribute_model' => Attribute::class,
+ 'options' => ['option_a', 'option_b'],
+ ],
+ 'attr'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'sr-ms-empty',
+ 'price' => 100,
+ ],
+ 'product_empty'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'sr-ms-a',
+ 'price' => 100,
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'product_a'
+ )
+ ]
+ public function testMultiselectDoesNotContainAppliesToProductWithoutValue(): void
+ {
+ $attributeCode = (string)$this->fixtures->get('attr')->getAttributeCode();
+ $optionA = (string)$this->fixtures->get('attr')->getData('option_a');
+
+ $this->assertTrue(
+ $this->validateProductCondition('product_empty', $attributeCode, '!{}', $optionA),
+ 'Product without multiselect value must match "does not contain"'
+ );
+ $this->assertFalse(
+ $this->validateProductCondition('product_a', $attributeCode, '!{}', $optionA),
+ 'Product with excluded multiselect option must not match "does not contain"'
+ );
+ }
+
+ #[
+ DataFixture(
+ SelectAttributeFixture::class,
+ [
+ 'is_used_for_promo_rules' => true,
+ 'options' => ['option_a', 'option_b'],
+ ],
+ 'attr'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'sr-sel-empty',
+ 'price' => 100,
+ ],
+ 'product_empty'
+ ),
+ DataFixture(
+ ProductFixture::class,
+ [
+ 'sku' => 'sr-sel-a',
+ 'price' => 100,
+ 'custom_attributes' => [
+ [
+ 'attribute_code' => '$attr.attribute_code$',
+ 'value' => '$attr.option_a$',
+ ],
+ ],
+ ],
+ 'product_a'
+ )
+ ]
+ public function testSelectIsNotAppliesToProductWithoutValue(): void
+ {
+ $attributeCode = (string)$this->fixtures->get('attr')->getAttributeCode();
+ $optionA = (string)$this->fixtures->get('attr')->getData('option_a');
+
+ $this->assertTrue(
+ $this->validateProductCondition('product_empty', $attributeCode, '!=', $optionA),
+ 'Product without select value must match "is not"'
+ );
+ $this->assertFalse(
+ $this->validateProductCondition('product_a', $attributeCode, '!=', $optionA),
+ 'Product with excluded select option must not match "is not"'
+ );
+ }
+
+ /**
+ * @param string $productFixtureName
+ * @param string $attributeCode
+ * @param string $operator
+ * @param string $value
+ * @return bool
+ */
+ private function validateProductCondition(
+ string $productFixtureName,
+ string $attributeCode,
+ string $operator,
+ string $value
+ ): bool {
+ $productId = (int)$this->fixtures->get($productFixtureName)->getId();
+ $product = $this->productRepository->getById($productId, false, null, true);
+ $product->load($productId);
+
+ /** @var QuoteItem $quoteItem */
+ $quoteItem = $this->objectManager->create(QuoteItem::class);
+ $quoteItem->setProduct($product);
+ $quoteItem->setQty(1);
+
+ /** @var SalesRuleProductCondition $condition */
+ $condition = $this->objectManager->create(SalesRuleProductCondition::class);
+ $condition->setAttribute($attributeCode);
+ $condition->setOperator($operator);
+ $condition->setValue($value);
+
+ return (bool)$condition->validate($quoteItem);
+ }
+}