Repository: saleor/saleor

Issue:
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
    }
  }
}
```



Discussion:

Resolving patch:
diff --git a/saleor/graphql/api.py b/saleor/graphql/api.py
--- 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=None, **kwargs):
+        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
--- 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_database_id, 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,13 @@ def resolve_products(info, category_id, query):
 
 def resolve_product_types():
     return models.ProductType.objects.all().distinct()
+
+
+def resolve_product_variants(info, ids=None):
+    queryset = models.ProductVariant.objects.distinct()
+    if ids:
+        db_ids = [
+            get_database_id(info, node_id, only_type=ProductVariant)
+            for node_id in ids]
+        queryset = queryset.filter(pk__in=db_ids)
+    return queryset


Test patch:
diff --git a/tests/api/test_product.py b/tests/api/test_product.py
--- a/tests/api/test_product.py
+++ b/tests/api/test_product.py
@@ -13,7 +13,7 @@
 from saleor.core import TaxRateType
 from saleor.graphql.product.utils import update_variants_names
 from saleor.product.models import (
-    Category, Collection, Product, ProductImage, ProductType)
+    Category, Collection, Product, ProductImage, ProductType, ProductVariant)
 
 from .utils import assert_no_permission, get_multipart_request_body
 
@@ -1243,3 +1243,47 @@ 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
+
+
+def test_product_variants_no_ids_list(user_api_client, variant):
+    query = '''
+        query getProductVariants {
+            productVariants(first: 10) {
+                edges {
+                    node {
+                        id
+                    }
+                }
+            }
+        }
+        '''
+    response = user_api_client.post(
+        reverse('api'), {'query': query})
+    content = get_graphql_content(response)
+    assert 'errors' not in content
+    data = content['data']['productVariants']
+    assert len(data['edges']) == ProductVariant.objects.count()