Repository: saleor/saleor

Issue:
Allow to update/delete multiple metadata items at once
### What I'm trying to achieve
I'm trying to update/delete multiple metadata items. With the current implementation, I need to send multiple mutations, while I could just have one that updates/deletes multiple keys at once e.g. by passing input like `input: [{key: "key-a", value: "a"}, {key: "key-b", value: "b"}]`

### Describe a proposed solution
- [ ] change the signature of `updateMetadata` mutation to:
```
updateMetadata(id: ID!, input: [MetadataInput!]!): UpdateMetadata
```
- [ ] change `updatePrivateMetadata` accordingly

- [ ] change the signature of `deleteMetadata` to:
```
deleteMetadata(id: ID!, keys: [String!]!): DeleteMetadata
```
- [ ] change `deletePrivateMetadata` accordingly



Discussion:
@maarcingebala in `test_meta_mutations` we have tests for a single meta item for every model (User, Product, Order, etc). Do we want to have tests with multiple updated/deleted items for every model also?
I think it's fine if we refactor them to use lists with one element, but there need to be some tests that actually check that the logic works for more than one as well.

Resolving patch:
diff --git a/saleor/graphql/meta/mutations.py b/saleor/graphql/meta/mutations.py
--- a/saleor/graphql/meta/mutations.py
+++ b/saleor/graphql/meta/mutations.py
@@ -102,7 +102,8 @@ class Meta:
 
     class Arguments:
         id = graphene.ID(description="ID of an object to update.", required=True)
-        input = MetadataInput(
+        input = graphene.List(
+            graphene.NonNull(MetadataInput),
             description="Fields required to update the object's metadata.",
             required=True,
         )
@@ -111,9 +112,9 @@ class Arguments:
     def perform_mutation(cls, root, info, **data):
         instance = cls.get_instance(info, **data)
         if instance:
-            metadata = data.pop("input")
-            item = {metadata.key: metadata.value}
-            instance.store_value_in_metadata(items=item)
+            metadata_list = data.pop("input")
+            items = {data.key: data.value for data in metadata_list}
+            instance.store_value_in_metadata(items=items)
             instance.save(update_fields=["metadata"])
         return cls.success_response(instance)
 
@@ -127,14 +128,19 @@ class Meta:
 
     class Arguments:
         id = graphene.ID(description="ID of an object to update.", required=True)
-        key = graphene.String(description="Metadata key to delete.", required=True)
+        keys = graphene.List(
+            graphene.NonNull(graphene.String),
+            description="Metadata keys to delete.",
+            required=True,
+        )
 
     @classmethod
     def perform_mutation(cls, root, info, **data):
         instance = cls.get_instance(info, **data)
         if instance:
-            metadata_key = data.pop("key")
-            instance.delete_value_from_metadata(metadata_key)
+            metadata_keys = data.pop("keys")
+            for key in metadata_keys:
+                instance.delete_value_from_metadata(key)
             instance.save(update_fields=["metadata"])
         return cls.success_response(instance)
 
@@ -148,7 +154,8 @@ class Meta:
 
     class Arguments:
         id = graphene.ID(description="ID of an object to update.", required=True)
-        input = MetadataInput(
+        input = graphene.List(
+            graphene.NonNull(MetadataInput),
             description=("Fields required to update the object's metadata."),
             required=True,
         )
@@ -157,9 +164,9 @@ class Arguments:
     def perform_mutation(cls, root, info, **data):
         instance = cls.get_instance(info, **data)
         if instance:
-            metadata = data.pop("input")
-            item = {metadata.key: metadata.value}
-            instance.store_value_in_private_metadata(items=item)
+            metadata_list = data.pop("input")
+            items = {data.key: data.value for data in metadata_list}
+            instance.store_value_in_private_metadata(items=items)
             instance.save(update_fields=["private_metadata"])
         return cls.success_response(instance)
 
@@ -173,13 +180,18 @@ class Meta:
 
     class Arguments:
         id = graphene.ID(description="ID of an object to update.", required=True)
-        key = graphene.String(description="Metadata key to delete.", required=True)
+        keys = graphene.List(
+            graphene.NonNull(graphene.String),
+            description="Metadata keys to delete.",
+            required=True,
+        )
 
     @classmethod
     def perform_mutation(cls, root, info, **data):
         instance = cls.get_instance(info, **data)
         if instance:
-            metadata_key = data.pop("key")
-            instance.delete_value_from_private_metadata(metadata_key)
+            metadata_keys = data.pop("keys")
+            for key in metadata_keys:
+                instance.delete_value_from_private_metadata(key)
             instance.save(update_fields=["private_metadata"])
         return cls.success_response(instance)


Test patch:
diff --git a/tests/api/test_meta_mutations.py b/tests/api/test_meta_mutations.py
--- a/tests/api/test_meta_mutations.py
+++ b/tests/api/test_meta_mutations.py
@@ -11,11 +11,13 @@
 PRIVATE_VALUE = "private_vale"
 
 PUBLIC_KEY = "key"
+PUBLIC_KEY2 = "key2"
 PUBLIC_VALUE = "value"
+PUBLIC_VALUE2 = "value2"
 
 
 UPDATE_PUBLIC_METADATA_MUTATION = """
-mutation UpdatePublicMetadata($id: ID!, $input: MetadataInput!) {
+mutation UpdatePublicMetadata($id: ID!, $input: [MetadataInput!]!) {
     updateMetadata(
         id: $id
         input: $input
@@ -43,7 +45,31 @@ def execute_update_public_metadata_for_item(
 ):
     variables = {
         "id": item_id,
-        "input": {"key": key, "value": value},
+        "input": [{"key": key, "value": value}],
+    }
+
+    response = client.post_graphql(
+        UPDATE_PUBLIC_METADATA_MUTATION % item_type,
+        variables,
+        permissions=[permissions] if permissions else None,
+    )
+    response = get_graphql_content(response)
+    return response
+
+
+def execute_update_public_metadata_for_multiple_items(
+    client,
+    permissions,
+    item_id,
+    item_type,
+    key=PUBLIC_KEY,
+    value=PUBLIC_VALUE,
+    key2=PUBLIC_KEY2,
+    value2=PUBLIC_VALUE2,
+):
+    variables = {
+        "id": item_id,
+        "input": [{"key": key, "value": value}, {"key": key2, "value": value2}],
     }
 
     response = client.post_graphql(
@@ -64,6 +90,26 @@ def item_contains_proper_public_metadata(
     return item.get_value_from_metadata(key) == value
 
 
+def item_contains_multiple_proper_public_metadata(
+    item_from_response,
+    item,
+    item_id,
+    key=PUBLIC_KEY,
+    value=PUBLIC_VALUE,
+    key2=PUBLIC_KEY2,
+    value2=PUBLIC_VALUE2,
+):
+    if item_from_response["id"] != item_id:
+        return False
+    item.refresh_from_db()
+    return all(
+        [
+            item.get_value_from_metadata(key) == value,
+            item.get_value_from_metadata(key2) == value2,
+        ]
+    )
+
+
 def test_add_public_metadata_for_customer_as_staff(
     staff_api_client, permission_manage_users, customer_user
 ):
@@ -98,6 +144,23 @@ def test_add_public_metadata_for_customer_as_service_account(
     )
 
 
+def test_add_multiple_public_metadata_for_customer_as_service_account(
+    service_account_api_client, permission_manage_users, customer_user
+):
+    # given
+    customer_id = graphene.Node.to_global_id("User", customer_user.pk)
+
+    # when
+    response = execute_update_public_metadata_for_multiple_items(
+        service_account_api_client, permission_manage_users, customer_id, "User"
+    )
+
+    # then
+    assert item_contains_multiple_proper_public_metadata(
+        response["data"]["updateMetadata"]["item"], customer_user, customer_id
+    )
+
+
 def test_add_public_metadata_for_other_staff_as_staff(
     staff_api_client, permission_manage_staff, admin_user
 ):
@@ -428,10 +491,10 @@ def test_update_public_metadata_for_item_without_meta(api_client, address):
 
 
 DELETE_PUBLIC_METADATA_MUTATION = """
-mutation DeletePublicMetadata($id: ID!, $key: String!) {
+mutation DeletePublicMetadata($id: ID!, $keys: [String!]!) {
     deleteMetadata(
         id: $id
-        key: $key
+        keys: $keys
     ) {
         metadataErrors{
             field
@@ -456,7 +519,24 @@ def execute_clear_public_metadata_for_item(
 ):
     variables = {
         "id": item_id,
-        "key": key,
+        "keys": [key],
+    }
+
+    response = client.post_graphql(
+        DELETE_PUBLIC_METADATA_MUTATION % item_type,
+        variables,
+        permissions=[permissions] if permissions else None,
+    )
+    response = get_graphql_content(response)
+    return response
+
+
+def execute_clear_public_metadata_for_multiple_items(
+    client, permissions, item_id, item_type, key=PUBLIC_KEY, key2=PUBLIC_KEY2
+):
+    variables = {
+        "id": item_id,
+        "keys": [key, key2],
     }
 
     response = client.post_graphql(
@@ -477,6 +557,26 @@ def item_without_public_metadata(
     return item.get_value_from_metadata(key) != value
 
 
+def item_without_multiple_public_metadata(
+    item_from_response,
+    item,
+    item_id,
+    key=PUBLIC_KEY,
+    value=PUBLIC_VALUE,
+    key2=PUBLIC_KEY2,
+    value2=PUBLIC_VALUE2,
+):
+    if item_from_response["id"] != item_id:
+        return False
+    item.refresh_from_db()
+    return all(
+        [
+            item.get_value_from_metadata(key) != value,
+            item.get_value_from_metadata(key2) != value2,
+        ]
+    )
+
+
 def test_delete_public_metadata_for_customer_as_staff(
     staff_api_client, permission_manage_users, customer_user
 ):
@@ -515,6 +615,27 @@ def test_delete_public_metadata_for_customer_as_service_account(
     )
 
 
+def test_delete_multiple_public_metadata_for_customer_as_service_account(
+    service_account_api_client, permission_manage_users, customer_user
+):
+    # given
+    customer_user.store_value_in_metadata(
+        {PUBLIC_KEY: PUBLIC_VALUE, PUBLIC_KEY2: PUBLIC_VALUE2}
+    )
+    customer_user.save(update_fields=["metadata"])
+    customer_id = graphene.Node.to_global_id("User", customer_user.pk)
+
+    # when
+    response = execute_clear_public_metadata_for_multiple_items(
+        service_account_api_client, permission_manage_users, customer_id, "User"
+    )
+
+    # then
+    assert item_without_multiple_public_metadata(
+        response["data"]["deleteMetadata"]["item"], customer_user, customer_id
+    )
+
+
 def test_delete_public_metadata_for_other_staff_as_staff(
     staff_api_client, permission_manage_staff, admin_user
 ):
@@ -897,7 +1018,7 @@ def test_delete_public_metadata_for_one_key(api_client, checkout):
 
 
 UPDATE_PRIVATE_METADATA_MUTATION = """
-mutation UpdatePrivateMetadata($id: ID!, $input: MetadataInput!) {
+mutation UpdatePrivateMetadata($id: ID!, $input: [MetadataInput!]!) {
     updatePrivateMetadata(
         id: $id
         input: $input
@@ -925,7 +1046,31 @@ def execute_update_private_metadata_for_item(
 ):
     variables = {
         "id": item_id,
-        "input": {"key": key, "value": value},
+        "input": [{"key": key, "value": value}],
+    }
+
+    response = client.post_graphql(
+        UPDATE_PRIVATE_METADATA_MUTATION % item_type,
+        variables,
+        permissions=[permissions] if permissions else None,
+    )
+    response = get_graphql_content(response)
+    return response
+
+
+def execute_update_private_metadata_for_multiple_items(
+    client,
+    permissions,
+    item_id,
+    item_type,
+    key=PUBLIC_KEY,
+    value=PUBLIC_VALUE,
+    key2=PUBLIC_KEY2,
+    value2=PUBLIC_VALUE2,
+):
+    variables = {
+        "id": item_id,
+        "input": [{"key": key, "value": value}, {"key": key2, "value": value2}],
     }
 
     response = client.post_graphql(
@@ -946,6 +1091,26 @@ def item_contains_proper_private_metadata(
     return item.get_value_from_private_metadata(key) == value
 
 
+def item_contains_multiple_proper_private_metadata(
+    item_from_response,
+    item,
+    item_id,
+    key=PUBLIC_KEY,
+    value=PUBLIC_VALUE,
+    key2=PUBLIC_KEY2,
+    value2=PUBLIC_VALUE2,
+):
+    if item_from_response["id"] != item_id:
+        return False
+    item.refresh_from_db()
+    return all(
+        [
+            item.get_value_from_private_metadata(key) == value,
+            item.get_value_from_private_metadata(key2) == value2,
+        ]
+    )
+
+
 def test_add_private_metadata_for_customer_as_staff(
     staff_api_client, permission_manage_users, customer_user
 ):
@@ -980,6 +1145,23 @@ def test_add_private_metadata_for_customer_as_service_account(
     )
 
 
+def test_add_multiple_private_metadata_for_customer_as_service_account(
+    service_account_api_client, permission_manage_users, customer_user
+):
+    # given
+    customer_id = graphene.Node.to_global_id("User", customer_user.pk)
+
+    # when
+    response = execute_update_private_metadata_for_multiple_items(
+        service_account_api_client, permission_manage_users, customer_id, "User"
+    )
+
+    # then
+    assert item_contains_multiple_proper_private_metadata(
+        response["data"]["updatePrivateMetadata"]["item"], customer_user, customer_id
+    )
+
+
 def test_add_private_metadata_for_other_staff_as_staff(
     staff_api_client, permission_manage_staff, admin_user
 ):
@@ -1020,7 +1202,7 @@ def test_add_private_metadata_for_myself_as_customer_no_permission(user_api_clie
     customer = user_api_client.user
     variables = {
         "id": graphene.Node.to_global_id("User", customer.pk),
-        "input": {"key": PRIVATE_KEY, "value": PRIVATE_VALUE},
+        "input": [{"key": PRIVATE_KEY, "value": PRIVATE_VALUE}],
     }
 
     # when
@@ -1037,7 +1219,7 @@ def test_add_private_metadata_for_myself_as_staff(staff_api_client):
     staff = staff_api_client.user
     variables = {
         "id": graphene.Node.to_global_id("User", staff.pk),
-        "input": {"key": PRIVATE_KEY, "value": PRIVATE_VALUE},
+        "input": [{"key": PRIVATE_KEY, "value": PRIVATE_VALUE}],
     }
 
     # when
@@ -1330,10 +1512,10 @@ def test_update_private_metadata_for_item_without_meta(api_client, address):
 
 
 DELETE_PRIVATE_METADATA_MUTATION = """
-mutation DeletePrivateMetadata($id: ID!, $key: String!) {
+mutation DeletePrivateMetadata($id: ID!, $keys: [String!]!) {
     deletePrivateMetadata(
         id: $id
-        key: $key
+        keys: $keys
     ) {
         metadataErrors{
             field
@@ -1358,7 +1540,7 @@ def execute_clear_private_metadata_for_item(
 ):
     variables = {
         "id": item_id,
-        "key": key,
+        "keys": [key],
     }
 
     response = client.post_graphql(
@@ -1370,6 +1552,23 @@ def execute_clear_private_metadata_for_item(
     return response
 
 
+def execute_clear_private_metadata_for_multiple_items(
+    client, permissions, item_id, item_type, key=PUBLIC_KEY, key2=PUBLIC_KEY2
+):
+    variables = {
+        "id": item_id,
+        "keys": [key, key2],
+    }
+
+    response = client.post_graphql(
+        DELETE_PUBLIC_METADATA_MUTATION % item_type,
+        variables,
+        permissions=[permissions] if permissions else None,
+    )
+    response = get_graphql_content(response)
+    return response
+
+
 def item_without_private_metadata(
     item_from_response, item, item_id, key=PRIVATE_KEY, value=PRIVATE_VALUE,
 ):
@@ -1379,6 +1578,26 @@ def item_without_private_metadata(
     return item.get_value_from_private_metadata(key) != value
 
 
+def item_without_multiple_private_metadata(
+    item_from_response,
+    item,
+    item_id,
+    key=PUBLIC_KEY,
+    value=PUBLIC_VALUE,
+    key2=PUBLIC_KEY2,
+    value2=PUBLIC_VALUE2,
+):
+    if item_from_response["id"] != item_id:
+        return False
+    item.refresh_from_db()
+    return all(
+        [
+            item.get_value_from_private_metadata(key) != value,
+            item.get_value_from_private_metadata(key2) != value2,
+        ]
+    )
+
+
 def test_delete_private_metadata_for_customer_as_staff(
     staff_api_client, permission_manage_users, customer_user
 ):
@@ -1417,6 +1636,27 @@ def test_delete_private_metadata_for_customer_as_service_account(
     )
 
 
+def test_delete_multiple_private_metadata_for_customer_as_service_account(
+    service_account_api_client, permission_manage_users, customer_user
+):
+    # given
+    customer_user.store_value_in_metadata(
+        {PUBLIC_KEY: PUBLIC_VALUE, PUBLIC_KEY2: PUBLIC_VALUE2}
+    )
+    customer_user.save(update_fields=["metadata"])
+    customer_id = graphene.Node.to_global_id("User", customer_user.pk)
+
+    # when
+    response = execute_clear_private_metadata_for_multiple_items(
+        service_account_api_client, permission_manage_users, customer_id, "User"
+    )
+
+    # then
+    assert item_without_multiple_private_metadata(
+        response["data"]["deleteMetadata"]["item"], customer_user, customer_id
+    )
+
+
 def test_delete_private_metadata_for_other_staff_as_staff(
     staff_api_client, permission_manage_staff, admin_user
 ):
@@ -1463,7 +1703,7 @@ def test_delete_private_metadata_for_myself_as_customer_no_permission(user_api_c
     customer.save(update_fields=["private_metadata"])
     variables = {
         "id": graphene.Node.to_global_id("User", customer.pk),
-        "key": PRIVATE_KEY,
+        "keys": [PRIVATE_KEY],
     }
 
     # when
@@ -1484,7 +1724,7 @@ def test_delete_private_metadata_for_myself_as_staff_no_permission(
     staff.save(update_fields=["private_metadata"])
     variables = {
         "id": graphene.Node.to_global_id("User", staff.pk),
-        "key": PRIVATE_KEY,
+        "keys": [PRIVATE_KEY],
     }
 
     # when