Repository: saleor/saleor

Issue:
Bug: Duplicate attribute values
### What are you trying to achieve?

When assigning attributes for a product, by using a slug, it returns duplicated values. Seems like the API sometimes create a new value instead of using an existing one.

### Steps to reproduce the problem

1. Create a list of attribute values that are similar to each other eg test1, test2, Test3, test-2
2. Fetch the product by the given ID, including the field of attributes, example query:
```graphql
query getProduct($id: ID!) {
  product(id: $id, channel: "") {
    attributes {
      attribute {
        id
        name
        choices(first: 10) {
          edges {
            node {
              name
              value
              slug
            }
          }
        }
      }
      values {
        slug
        name
      }
    }
  }
}
```
vars:
```json
{
  "id": "xxxx",
}
```
3. Grab some of the attribute slugs from the choices list
4. Use it to assign the attribute, example query:
```graphql
 mutation updateProduct($id: ID!, $input: ProductInput!) {
   productUpdate(id: $id, input: $input) {
     product {
       attributes {
         values {
           slug
           name
         }
       }
     }
   }
}
```
vars:
```json
{
  "id": "xxx",
  "input": {
    "attributes": [
      {
        "id": "xxx",
        "values": [
          "test1-2"
        ]
      }
    ],
    "category": "aaaaa",
    "chargeTaxes": true,
    "collections": [],
    "description": null,
    "name": "aaaa",
    "rating": null,
    "slug": "aaaa",
    "taxCode": null
  }
}
```
5. You end up with the wrong attribute assigned to the product

### What did you expect to happen?

Atm seems like API works properly when we send names/values of an attribute but breaks when I send the slug. I think the algorithm could be like this:

When the user sends the value:
1. use it as a slug, if an attribute found, return its value
2. use it as value, if an attribute is found, return its value
3. if not found, create a new value, return it

It is still not perfect, but that can be a hotfix

### Logs

_No response_

### Environment

Saleor version: …
OS and version: …



Discussion:
RFC for assigning values by some identifier: https://github.com/saleor/saleor/issues/10915

Resolving patch:
diff --git a/saleor/core/utils/__init__.py b/saleor/core/utils/__init__.py
--- a/saleor/core/utils/__init__.py
+++ b/saleor/core/utils/__init__.py
@@ -1,6 +1,6 @@
 import os
 import socket
-from typing import TYPE_CHECKING, Optional, Type, Union
+from typing import TYPE_CHECKING, Iterable, Optional, Type, Union
 from urllib.parse import urljoin
 
 from babel.numbers import get_territory_currencies
@@ -133,10 +133,8 @@ def generate_unique_slug(
 
     """
     slug = slugify(slugable_value, allow_unicode=True)
-    unique_slug: Union["SafeText", str] = slug
 
     ModelClass = instance.__class__
-    extension = 1
 
     search_field = f"{slug_field_name}__iregex"
     pattern = rf"{slug}-\d+$|{slug}$"
@@ -146,6 +144,16 @@ def generate_unique_slug(
         .values_list(slug_field_name, flat=True)
     )
 
+    unique_slug = prepare_unique_slug(slug, slug_values)
+
+    return unique_slug
+
+
+def prepare_unique_slug(slug: str, slug_values: Iterable):
+    """Prepare unique slug value based on provided list of existing slug values."""
+    unique_slug: Union["SafeText", str] = slug
+    extension = 1
+
     while unique_slug in slug_values:
         extension += 1
         unique_slug = f"{slug}-{extension}"
diff --git a/saleor/graphql/attribute/utils.py b/saleor/graphql/attribute/utils.py
--- a/saleor/graphql/attribute/utils.py
+++ b/saleor/graphql/attribute/utils.py
@@ -17,13 +17,13 @@
 from ...attribute import AttributeEntityType, AttributeInputType, AttributeType
 from ...attribute import models as attribute_models
 from ...attribute.utils import associate_attribute_values_to_instance
-from ...core.utils import generate_unique_slug
+from ...core.utils import generate_unique_slug, prepare_unique_slug
 from ...core.utils.editorjs import clean_editor_js
 from ...page import models as page_models
 from ...page.error_codes import PageErrorCode
 from ...product import models as product_models
 from ...product.error_codes import ProductErrorCode
-from ..core.utils import from_global_id_or_error
+from ..core.utils import from_global_id_or_error, get_duplicated_values
 from ..utils import get_nodes
 
 if TYPE_CHECKING:
@@ -144,21 +144,11 @@ def _pre_save_values(
         cls, attribute: attribute_models.Attribute, attr_values: AttrValuesInput
     ):
         """Lazy-retrieve or create the database objects from the supplied raw values."""
-        result = []
         if not attr_values.values:
             return tuple()
-        for value in attr_values.values:
-            value_obj = attribute.values.filter(name=value).first()
-            if value_obj:
-                result.append(value_obj)
-            else:
-                instance = attribute_models.AttributeValue(
-                    attribute=attribute, name=value
-                )
-                slug = generate_unique_slug(instance, value)  # type: ignore
-                instance.slug = slug
-                instance.save()
-                result.append(instance)
+
+        result = prepare_attribute_values(attribute, attr_values)
+
         return tuple(result)
 
     @classmethod
@@ -534,6 +524,56 @@ def get_variant_selection_attributes(qs: "QuerySet") -> "QuerySet":
     )
 
 
+def prepare_attribute_values(
+    attribute: attribute_models.Attribute, attr_values: AttrValuesInput
+):
+    values = attr_values.values
+    slug_to_value_map = {}
+    name_to_value_map = {}
+    for val in attribute.values.filter(Q(name__in=values) | Q(slug__in=values)):
+        slug_to_value_map[val.slug] = val
+        name_to_value_map[val.name] = val
+
+    existing_slugs = get_existing_slugs(attribute, values)
+
+    result = []
+    values_to_create = []
+    for value in values:
+        # match the value firstly by slug then by name
+        value_obj = slug_to_value_map.get(value) or name_to_value_map.get(value)
+        if value_obj:
+            result.append(value_obj)
+        else:
+            slug = prepare_unique_slug(
+                slugify(value, allow_unicode=True), existing_slugs
+            )
+            instance = attribute_models.AttributeValue(
+                attribute=attribute, name=value, slug=slug
+            )
+            result.append(instance)
+
+            values_to_create.append(instance)
+
+            # the set of existing slugs must be updated to not generate accidentally
+            # the same slug for two or more values
+            existing_slugs.add(slug)
+
+            # extend name to slug value to not create two elements with the same name
+            name_to_value_map[instance.name] = instance
+
+    attribute_models.AttributeValue.objects.bulk_create(values_to_create)
+    return result
+
+
+def get_existing_slugs(attribute: attribute_models.Attribute, values: List[str]):
+    lookup = Q()
+    for value in values:
+        lookup |= Q(slug__startswith=slugify(value, allow_unicode=True))
+
+    existing_slugs = set(attribute.values.filter(lookup).values_list("slug", flat=True))
+    return existing_slugs
+
+
 class AttributeInputErrors:
     """Define error message and error code for given error.
 
@@ -552,6 +592,10 @@ class AttributeInputErrors:
         "Attribute values cannot be blank.",
         "REQUIRED",
     )
+    ERROR_DUPLICATED_VALUES = (
+        "Duplicated attribute values are provided.",
+        "DUPLICATED_INPUT_ITEM",
+    )
 
     # file errors
     ERROR_NO_FILE_GIVEN = (
@@ -745,6 +789,10 @@ def validate_values(
 ):
     name_field = attribute.values.model.name.field  # type: ignore
     is_numeric = attribute.input_type == AttributeInputType.NUMERIC
+    if get_duplicated_values(values):
+        attribute_errors[AttributeInputErrors.ERROR_DUPLICATED_VALUES].append(
+            attribute_id
+        )
     for value in values:
         if value is None or (not is_numeric and not value.strip()):
             attribute_errors[AttributeInputErrors.ERROR_BLANK_VALUE].append(


Test patch:
diff --git a/saleor/graphql/attribute/tests/test_utils.py b/saleor/graphql/attribute/tests/test_utils.py
--- a/saleor/graphql/attribute/tests/test_utils.py
+++ b/saleor/graphql/attribute/tests/test_utils.py
@@ -5,7 +5,11 @@
 from ....page.error_codes import PageErrorCode
 from ....product.error_codes import ProductErrorCode
 from ...product.mutations.products import AttrValuesInput
-from ..utils import AttributeAssignmentMixin, validate_attributes_input
+from ..utils import (
+    AttributeAssignmentMixin,
+    prepare_attribute_values,
+    validate_attributes_input,
+)
 
 
 @pytest.mark.parametrize("creation", [True, False])
@@ -706,6 +710,59 @@ def test_validate_attributes_input_no_values_given(
     }
 
 
+@pytest.mark.parametrize("creation", [True, False])
+def test_validate_attributes_duplicated_values_given(
+    creation, weight_attribute, color_attribute, product_type
+):
+    # given
+    color_attribute.value_required = True
+    color_attribute.input_type = AttributeInputType.MULTISELECT
+    color_attribute.save(update_fields=["value_required"])
+
+    weight_attribute.value_required = True
+    weight_attribute.input_type = AttributeInputType.MULTISELECT
+    weight_attribute.save(update_fields=["value_required"])
+
+    input_data = [
+        (
+            weight_attribute,
+            AttrValuesInput(
+                global_id=graphene.Node.to_global_id("Attribute", weight_attribute.pk),
+                values=["test", "new", "test"],
+                file_url=None,
+                content_type=None,
+                references=[],
+            ),
+        ),
+        (
+            color_attribute,
+            AttrValuesInput(
+                global_id=graphene.Node.to_global_id("Attribute", color_attribute.pk),
+                values=["test", "test"],
+                file_url=None,
+                content_type=None,
+                references=[],
+            ),
+        ),
+    ]
+
+    attributes = product_type.variant_attributes.all()
+
+    # when
+    errors = validate_attributes_input(
+        input_data, attributes, is_page_attributes=False, creation=creation
+    )
+
+    # then
+    assert len(errors) == 1
+    error = errors[0]
+    assert error.code == ProductErrorCode.DUPLICATED_INPUT_ITEM.value
+    assert set(error.params["attributes"]) == {
+        graphene.Node.to_global_id("Attribute", attr.pk)
+        for attr in [weight_attribute, color_attribute]
+    }
+
+
 @pytest.mark.parametrize("creation", [True, False])
 def test_validate_not_required_variant_selection_attributes_input_no_values_given(
     creation, weight_attribute, color_attribute, product_type
@@ -1310,3 +1367,89 @@ def test_clean_file_url_in_attribute_assignment_mixin(file_url, expected_value):
     result = AttributeAssignmentMixin._clean_file_url(file_url)
 
     assert result == expected_value
+
+
+def test_prepare_attribute_values(color_attribute):
+    # given
+    existing_value = color_attribute.values.first()
+    attr_values_count = color_attribute.values.count()
+    new_value = existing_value.name.upper()
+    values = AttrValuesInput(
+        global_id=graphene.Node.to_global_id("Attribute", color_attribute.pk),
+        # we should get the new value only for the last element
+        values=[existing_value.name, existing_value.slug, new_value],
+        file_url=None,
+        content_type=None,
+        references=[],
+    )
+
+    # when
+    prepare_attribute_values(color_attribute, values)
+
+    # then
+    color_attribute.refresh_from_db()
+    assert color_attribute.values.count() == attr_values_count + 1
+    assert color_attribute.values.last().name == new_value
+
+
+def test_prepare_attribute_values_prefer_the_slug_match(color_attribute):
+    """Ensure that the value with slug match is returned as the first choice.
+
+    When the value with the matching slug is not found, the value with the matching
+    name is returned."""
+    # given
+    existing_value = color_attribute.values.first()
+    second_val = color_attribute.values.create(
+        name=existing_value.slug, slug=f"{existing_value.slug}-2"
+    )
+
+    attr_values_count = color_attribute.values.count()
+
+    values = AttrValuesInput(
+        global_id=graphene.Node.to_global_id("Attribute", color_attribute.pk),
+        # we should get the new value only for the last element
+        values=[existing_value.name, second_val.name, second_val.slug],
+        file_url=None,
+        content_type=None,
+        references=[],
+    )
+
+    # when
+    result = prepare_attribute_values(color_attribute, values)
+
+    # then
+    color_attribute.refresh_from_db()
+    assert color_attribute.values.count() == attr_values_count
+    assert result == [existing_value, existing_value, second_val]
+
+
+def test_prepare_attribute_values_that_gives_the_same_slug(color_attribute):
+    """Ensure that the unique slug for all values is created.
+
+    Ensure that when providing the two or more values that are giving the same slug
+    the integrity error is not raised."""
+    # given
+    existing_value = color_attribute.values.first()
+    attr_values_count = color_attribute.values.count()
+    new_value = "RED"
+    new_value_2 = "ReD"
+
+    values = AttrValuesInput(
+        global_id=graphene.Node.to_global_id("Attribute", color_attribute.pk),
+        # we should get the new value only for the last element
+        values=[existing_value.name, new_value, new_value_2],
+        file_url=None,
+        content_type=None,
+        references=[],
+    )
+
+    # when
+    result = prepare_attribute_values(color_attribute, values)
+
+    # then
+    color_attribute.refresh_from_db()
+    assert color_attribute.values.count() == attr_values_count + 2
+    assert len(result) == 3
+    assert result[0] == existing_value
+    assert result[1].name == new_value
+    assert result[2].name == new_value_2
diff --git a/saleor/graphql/product/tests/test_variant.py b/saleor/graphql/product/tests/test_variant.py
--- a/saleor/graphql/product/tests/test_variant.py
+++ b/saleor/graphql/product/tests/test_variant.py
@@ -2374,6 +2374,98 @@ def test_update_product_variant_with_matching_slugs_different_values(
     assert variant.attributes.last().values.first().slug == "small-2"
 
 
+def test_update_product_variant_with_value_that_matching_existing_slug(
+    staff_api_client,
+    product_with_variant_with_two_attributes,
+    permission_manage_products,
+):
+    product = product_with_variant_with_two_attributes
+    variant = product.variants.first()
+    sku = str(uuid4())[:12]
+    assert not variant.sku == sku
+
+    attribute_1, attribute_2 = product.product_type.variant_attributes.all()
+
+    variant_id = graphene.Node.to_global_id("ProductVariant", variant.pk)
+    attribute_1_id = graphene.Node.to_global_id("Attribute", attribute_1.pk)
+    attribute_2_id = graphene.Node.to_global_id("Attribute", attribute_2.pk)
+
+    attr_1_values_count = attribute_1.values.count()
+    attr_2_values_count = attribute_2.values.count()
+
+    variables = {
+        "id": variant_id,
+        "sku": sku,
+        "attributes": [
+            {"id": attribute_1_id, "values": [attribute_1.values.first().slug]},
+            {"id": attribute_2_id, "values": [attribute_2.values.first().slug]},
+        ],
+    }
+
+    response = staff_api_client.post_graphql(
+        QUERY_UPDATE_VARIANT_ATTRIBUTES,
+        variables,
+        permissions=[permission_manage_products],
+    )
+    content = get_graphql_content(response)
+    data = content["data"]["productVariantUpdate"]
+    assert not data["errors"]
+    variant.refresh_from_db()
+    assert variant.sku == sku
+    assert attribute_1.values.count() == attr_1_values_count
+    assert attribute_2.values.count() == attr_2_values_count
+    assert len(data["productVariant"]["attributes"]) == 2
+    for attr_data in data["productVariant"]["attributes"]:
+        assert len(attr_data["values"]) == 1
+
+
+def test_update_product_variant_with_value_that_matching_existing_name(
+    staff_api_client,
+    product_with_variant_with_two_attributes,
+    permission_manage_products,
+):
+    product = product_with_variant_with_two_attributes
+    variant = product.variants.first()
+    sku = str(uuid4())[:12]
+    assert not variant.sku == sku
+
+    attribute_1, attribute_2 = product.product_type.variant_attributes.all()
+
+    variant_id = graphene.Node.to_global_id("ProductVariant", variant.pk)
+    attribute_1_id = graphene.Node.to_global_id("Attribute", attribute_1.pk)
+    attribute_2_id = graphene.Node.to_global_id("Attribute", attribute_2.pk)
+
+    attr_1_values_count = attribute_1.values.count()
+    attr_2_values_count = attribute_2.values.count()
+
+    variables = {
+        "id": variant_id,
+        "sku": sku,
+        "attributes": [
+            {"id": attribute_1_id, "values": [attribute_1.values.first().name]},
+            {"id": attribute_2_id, "values": [attribute_2.values.first().name]},
+        ],
+    }
+
+    response = staff_api_client.post_graphql(
+        QUERY_UPDATE_VARIANT_ATTRIBUTES,
+        variables,
+        permissions=[permission_manage_products],
+    )
+    content = get_graphql_content(response)
+    data = content["data"]["productVariantUpdate"]
+    assert not data["errors"]
+    variant.refresh_from_db()
+    attribute_1.refresh_from_db()
+    attribute_2.refresh_from_db()
+    assert variant.sku == sku
+    assert attribute_1.values.count() == attr_1_values_count
+    assert attribute_2.values.count() == attr_2_values_count
+    assert len(data["productVariant"]["attributes"]) == 2
+    for attr_data in data["productVariant"]["attributes"]:
+        assert len(attr_data["values"]) == 1
+
+
 @patch("saleor.plugins.manager.PluginsManager.product_variant_updated")
 def test_update_variant_with_boolean_attribute(
     product_variant_updated,