Repository: saleor/saleor

Issue:
Use enums as sorting arguments
In queries, the supported sorting options should be represented as enums. Now we use strings, so API consumers are not able to determine what options are available.

Currently, this issue applies only to one query:
- `products(sortBy: String)`

Available sorting fields (based on Dashboard 2.0 and storefront) are now: `price`, `name`.

Schema that would solve the issue (inspired by Github API v4):
```
enum ProductOrderField {
  NAME
  PRICE
}

enum OrderDirection {
  ASC
  DESC
}

type ProductOrder {
  field: ProductOrderField!
  direction: OrderDirection!
}

type Query {
  products(sortBy: ProductOrder)
}
```




Discussion:

Resolving patch:
diff --git a/saleor/graphql/product/filters.py b/saleor/graphql/product/filters.py
--- a/saleor/graphql/product/filters.py
+++ b/saleor/graphql/product/filters.py
@@ -54,7 +54,8 @@ def filter_products_by_collections(qs, collections):
     return qs.filter(collections__in=collections)
 
 
-def sort_qs(qs, sort_by):
-    if sort_by:
-        qs = qs.order_by(sort_by)
+def sort_qs(qs, sort_by_product_order):
+    if sort_by_product_order:
+        qs = qs.order_by(sort_by_product_order['direction']
+                         + sort_by_product_order['field'])
     return qs
diff --git a/saleor/graphql/product/schema.py b/saleor/graphql/product/schema.py
--- a/saleor/graphql/product/schema.py
+++ b/saleor/graphql/product/schema.py
@@ -1,30 +1,28 @@
 import graphene
 from graphql_jwt.decorators import permission_required
 
-from ..descriptions import DESCRIPTIONS
 from ..core.fields import PrefetchingConnectionField
 from ..core.types import ReportingPeriod
+from ..descriptions import DESCRIPTIONS
 from .mutations.attributes import (
-    AttributeValueCreate, AttributeValueDelete,
-    AttributeValueUpdate, AttributeCreate, AttributeDelete,
-    AttributeUpdate)
+    AttributeCreate, AttributeDelete, AttributeUpdate, AttributeValueCreate,
+    AttributeValueDelete, AttributeValueUpdate)
 from .mutations.products import (
-    CategoryCreate, CategoryDelete, CategoryUpdate,
-    CollectionAddProducts, CollectionCreate, CollectionDelete,
-    CollectionRemoveProducts, CollectionUpdate, ProductCreate,
-    ProductDelete, ProductUpdate, ProductTypeCreate,
-    ProductTypeDelete, ProductImageCreate, ProductImageDelete,
-    ProductImageReorder, ProductImageUpdate, ProductTypeUpdate,
-    ProductVariantCreate, ProductVariantDelete,
-    ProductVariantUpdate, VariantImageAssign, VariantImageUnassign)
+    CategoryCreate, CategoryDelete, CategoryUpdate, CollectionAddProducts,
+    CollectionCreate, CollectionDelete, CollectionRemoveProducts,
+    CollectionUpdate, ProductCreate, ProductDelete, ProductImageCreate,
+    ProductImageDelete, ProductImageReorder, ProductImageUpdate,
+    ProductTypeCreate, ProductTypeDelete, ProductTypeUpdate, ProductUpdate,
+    ProductVariantCreate, ProductVariantDelete, ProductVariantUpdate,
+    VariantImageAssign, VariantImageUnassign)
 from .resolvers import (
     resolve_attributes, resolve_categories, resolve_collections,
-    resolve_products, resolve_product_types, resolve_product_variants,
+    resolve_product_types, resolve_product_variants, resolve_products,
     resolve_report_product_sales)
 from .scalars import AttributeScalar
 from .types import (
-    Category, Collection, Product, Attribute, ProductType, ProductVariant,
-    StockAvailability)
+    Attribute, Category, Collection, Product, ProductOrder, ProductType,
+    ProductVariant, StockAvailability)
 
 
 class ProductQueries(graphene.ObjectType):
@@ -63,7 +61,8 @@ class ProductQueries(graphene.ObjectType):
             description='Filter by price less than or equal to the given value.'),
         price_gte=graphene.Float(
             description='Filter by price greater than or equal to the given value.'),
-        sort_by=graphene.String(description='Sort products.'),
+        sort_by=graphene.Argument(
+            ProductOrder, description='Sort products.'),
         stock_availability=graphene.Argument(
             StockAvailability,
             description='Filter products by the stock availability'),
diff --git a/saleor/graphql/product/types.py b/saleor/graphql/product/types.py
--- a/saleor/graphql/product/types.py
+++ b/saleor/graphql/product/types.py
@@ -42,6 +42,28 @@ class StockAvailability(graphene.Enum):
     OUT_OF_STOCK = 'OUT_OF_STOCK'
 
 
+class ProductOrderField(graphene.Enum):
+    NAME = 'name'
+    PRICE = 'price'
+
+    @property
+    def description(self):
+        if self == ProductOrderField.NAME:
+            return 'Sort products by name.'
+        return 'Sort products by price.'
+
+
+class OrderDirection(graphene.Enum):
+    ASC = ''
+    DESC = '-'
+
+    @property
+    def description(self):
+        if self == OrderDirection.ASC:
+            return 'Specifies an ascending sort order.'
+        return 'Specifies a descending sort order.'
+
+
 def resolve_attribute_list(attributes_hstore, attributes_qs):
     """Resolve attributes dict into a list of `SelectedAttribute`s.
     keys = list(attributes.keys())
@@ -129,6 +151,15 @@ class Meta:
         description = 'Represents a custom attribute.'
 
 
+class ProductOrder(graphene.InputObjectType):
+    field = graphene.Argument(
+        ProductOrderField, required=True,
+        description='Sort products by the selected field.')
+    direction = graphene.Argument(
+        OrderDirection, required=True,
+        description='Specifies the direction in which to sort products')
+
+
 class ProductVariant(CountableDjangoObjectType):
     stock_quantity = graphene.Int(
         required=True, description='Quantity of a product available for sale.')


Test patch:
diff --git a/tests/api/test_graphql.py b/tests/api/test_graphql.py
--- a/tests/api/test_graphql.py
+++ b/tests/api/test_graphql.py
@@ -45,7 +45,7 @@ def test_real_query(user_api_client, product):
     attr_value = product_attr.values.first()
     filter_by = '%s:%s' % (product_attr.slug, attr_value.slug)
     query = """
-    query Root($categoryId: ID!, $sortBy: String, $first: Int, $attributesFilter: [AttributeScalar], $minPrice: Float, $maxPrice: Float) {
+    query Root($categoryId: ID!, $sortBy: ProductOrder, $first: Int, $attributesFilter: [AttributeScalar], $minPrice: Float, $maxPrice: Float) {
         category(id: $categoryId) {
             ...CategoryPageFragmentQuery
             __typename
@@ -178,7 +178,9 @@ def test_real_query(user_api_client, product):
     variables = {
         'categoryId': graphene.Node.to_global_id(
             'Category', category.id),
-        'sortBy': 'name',
+        'sortBy': {
+            'field': 'NAME',
+            'direction': 'ASC'},
         'first': 1,
         'attributesFilter': [filter_by]}
     response = user_api_client.post_graphql(query, variables)
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
@@ -311,7 +311,7 @@ def test_sort_products(user_api_client, product):
 
     query = """
     query {
-        products(sortBy: "%(sort_by)s") {
+        products(sortBy: %(sort_by_product_order)s) {
             edges {
                 node {
                     price {
@@ -323,15 +323,16 @@ def test_sort_products(user_api_client, product):
     }
     """
 
-    asc_price_query = query % {'sort_by': 'price'}
+    asc_price_query = query % {
+        'sort_by_product_order': '{field: PRICE, direction:ASC}'}
     response = user_api_client.post_graphql(asc_price_query)
     content = get_graphql_content(response)
-    content['data']['products']['edges'][0]['node']
     price_0 = content['data']['products']['edges'][0]['node']['price']['amount']
     price_1 = content['data']['products']['edges'][1]['node']['price']['amount']
     assert price_0 < price_1
 
-    desc_price_query = query % {'sort_by': '-price'}
+    desc_price_query = query % {
+        'sort_by_product_order': '{field: PRICE, direction:DESC}'}
     response = user_api_client.post_graphql(desc_price_query)
     content = get_graphql_content(response)
     price_0 = content['data']['products']['edges'][0]['node']['price']['amount']