Repository: saleor/saleor
Pull request: Allow to query variants by list of ids
I want to merge this change because it closes #2741 

### Pull Request Checklist

<!-- Please keep this section. It will make maintainer's life easier. -->

1. [x] Privileged views and APIs are guarded by proper permission checks.
1. [x] All visible strings are translated with proper context.
1. [x] All data-formatting is locale-aware (dates, numbers, and so on).
1. [x] Database queries are optimized and the number of queries is constant.
1. [x] The changes are tested.
1. [ ] The code is documented (docstrings, project documentation).


Problem statement:
Add productVariants query
### What I'm trying to achieve
I would like to get more than one variant from API by ids, right now we support only the `productVariant(id: "xyz"}` query.

I need this feature for the cart feature in the client app. A query which I am thinking of should look like:
```graphql
  productVariants(ids: ["UHJvZHVjdFZhcmlhbnQ6Mg==", "JHDaFZhcmlhbnQ6Mg=="]) {
    id,
    stockQuantity,
    costPrice {
      currency,
      amount
    },
    product {
      id,
      name,
      thumbnailUrl
    }
  }
}
```

Hints from issue discussion:

Commit message: Allow to query variants by list of ids

Patch to review:
diff --git a/saleor/graphql/api.py b/saleor/graphql/api.py
index 451d572b83e..7d4ecf6ee86 100644
--- a/saleor/graphql/api.py
+++ b/saleor/graphql/api.py
@@ -51,7 +51,7 @@
     ProductVariantUpdate, VariantImageAssign, VariantImageUnassign)
 from .product.resolvers import (
     resolve_attributes, resolve_categories, resolve_collections,
-    resolve_products, resolve_product_types)
+    resolve_products, resolve_product_types, resolve_product_variants)
 from .product.types import (
     Category, Collection, Product, ProductAttribute, ProductType,
     ProductVariant)
@@ -130,6 +130,10 @@ class Query(graphene.ObjectType):
     product_variant = graphene.Field(
         ProductVariant, id=graphene.Argument(graphene.ID),
         description='Lookup a variant by ID.')
+    product_variants = DjangoFilterConnectionField(
+        ProductVariant, filterset_class=DistinctFilterSet,
+        ids=graphene.List(graphene.ID),
+        description='Lookup multiple variants by ID')
     sale = graphene.Field(
         Sale, id=graphene.Argument(graphene.ID),
         description='Lookup a sale by ID.')
@@ -232,6 +236,9 @@ def resolve_sales(self, info, query=None, **kwargs):
     def resolve_product_variant(self, info, id):
         return graphene.Node.get_node_from_global_id(info, id, ProductVariant)
 
+    def resolve_product_variants(self, info, ids):
+        return resolve_product_variants(info, ids)
+
     @permission_required('discount.manage_discounts')
     def resolve_voucher(self, info, id):
         return graphene.Node.get_node_from_global_id(info, id, Voucher)
diff --git a/saleor/graphql/product/resolvers.py b/saleor/graphql/product/resolvers.py
index 53a7dafbd4a..8158caa19cd 100644
--- a/saleor/graphql/product/resolvers.py
+++ b/saleor/graphql/product/resolvers.py
@@ -3,8 +3,8 @@
 
 from ...product import models
 from ...product.utils import products_with_details
-from ..utils import filter_by_query_param
-from .types import Category
+from ..utils import filter_by_query_param, get_nodes
+from .types import Category, ProductVariant
 
 PRODUCT_SEARCH_FIELDS = ('name', 'description', 'category__name')
 CATEGORY_SEARCH_FIELDS = ('name', 'slug', 'description', 'parent__name')
@@ -65,3 +65,7 @@ def resolve_products(info, category_id, query):
 
 def resolve_product_types():
     return models.ProductType.objects.all().distinct()
+
+
+def resolve_product_variants(info, ids):
+    return get_nodes(ids, graphene_type=ProductVariant)
diff --git a/tests/api/test_product.py b/tests/api/test_product.py
index 1e0b120e25f..49827f511a4 100644
--- a/tests/api/test_product.py
+++ b/tests/api/test_product.py
@@ -1243,3 +1243,27 @@ def test_update_variants_changed_does_nothing_with_no_attributes():
     product_type.variant_attributes.all = Mock(return_value=[])
     saved_attributes = []
     assert update_variants_names(product_type, saved_attributes) is None
+
+
+def test_product_variants_by_ids(user_api_client, variant):
+    query = '''
+        query getProduct($ids: [ID!]) {
+            productVariants(ids: $ids) {
+                edges {
+                    node {
+                        id
+                    }
+                }
+            }
+        }
+        '''
+    variant_id = graphene.Node.to_global_id('ProductVariant', variant.id)
+
+    variables = json.dumps({'ids': [variant_id]})
+    response = user_api_client.post(
+        reverse('api'), {'query': query, 'variables': variables})
+    content = get_graphql_content(response)
+    assert 'errors' not in content
+    data = content['data']['productVariants']
+    assert data['edges'][0]['node']['id'] == variant_id
+    assert len(data['edges']) == 1


Code review comments:
diff_hunk: @@ -65,3 +65,7 @@ def resolve_products(info, category_id, query):
 
 def resolve_product_types():
     return models.ProductType.objects.all().distinct()
+
+
+def resolve_product_variants(info, ids):
path: saleor/graphql/product/resolvers.py
text: @user1:
To unify this endpoint the rest of connection-endpoints in our API I would make the `ids` argument optional. If it's not provided the endpoint would behave just as all other paginated connections. But when providing the list of `ids` we would filter the results and only return the requested IDs.

@user2:
That requires some extra permissions. A normal user won't be able to list all checkouts only its own.

@user1:
I assume you'll use a different query for checkouts, this one only returns variants. But it is true that similarly to how we filter products in the `products` query, here we should make sure that we don't expose variants of unpublished products publicly.

@user2:
Sorry for the misunderstanding, my mind was attached to checkout API.

@user1:
Wat? 😄

@user1:
![image](https://user-images.githubusercontent.com/5421321/45087612-d1a56e00-b106-11e8-8991-bf5addac43f7.png)
(credits: @user3)