Repository: saleor/saleor

Issue:
All cursors are identical (and unworking) when using `filter` on `query.products`
### What I'm trying to achieve
We are attempting to paginate a filtered `products` query. However, all cursors reported are identical when using a search filter.

### Steps to reproduce the problem
1. Visit a playground with sufficient products that you can do a filtered search of them
2. Run a query similar to the following (but with a search term and channel that are appropriate to the install):
    ```
    query {
      products (
        first: 3, filter: { search: "Camera" }, channel:"msrp"
      ) {
        edges {
          node {
            id
          }
          cursor
        }
        pageInfo {
          hasNextPage
          hasPreviousPage
          startCursor
          endCursor
        }
        totalCount
      }
    }
    ```
3. Note that all reported cursors are identical. If you add `after: "CURSOR-HERE"` to the arguments, you receive the same list of products. 

### What I expected to happen
Cursors should be unique so that you can start pagination at those points.

### Screenshots and logs
Please let me know if you are unable to reproduce this; here's what it looks like for us:

![Screen Shot 2021-09-02 at 2 52 32 PM](https://user-images.githubusercontent.com/33501/131921413-c2cef764-3181-4f9e-8b6e-5b28b3b8bb71.png)


**System information**
<!-- Provide the version of Saleor or whether you're using it from the `master` branch. If using Saleor Dashboard or Storefront, provide their versions too. -->
Saleor version:
- [ ] dev (current master)
- [x] 3.0
- [ ] 2.11
- [ ] 2.10



Discussion:
If you need a workaround you can just add `sortBy:{field:NAME, direction:ASC}` to query. 

Resolving patch:
diff --git a/saleor/graphql/core/connection.py b/saleor/graphql/core/connection.py
--- a/saleor/graphql/core/connection.py
+++ b/saleor/graphql/core/connection.py
@@ -1,4 +1,5 @@
 import json
+from decimal import Decimal, InvalidOperation
 from typing import Any, Dict, Iterable, List, Tuple, Union
 
 import graphene
@@ -14,6 +15,8 @@
 
 ConnectionArguments = Dict[str, Any]
 
+EPSILON = Decimal("0.000001")
+
 
 def to_global_cursor(values):
     if not isinstance(values, Iterable):
@@ -39,6 +42,28 @@ def get_field_value(instance: DjangoModel, field_name: str):
     return attr
 
 
+def _prepare_filter_by_rank_expression(
+    cursor: List[str],
+    sorting_direction: str,
+) -> Q:
+    try:
+        rank = Decimal(cursor[0])
+        int(cursor[1])
+    except (InvalidOperation, ValueError, TypeError, KeyError):
+        raise ValueError("Invalid cursor for sorting by rank.")
+
+    # Because rank is float number, it gets mangled by PostgreSQL's query parser
+    # making equal comparisons impossible. Instead we compare rank against small
+    # range of values, constructed using epsilon.
+    if sorting_direction == "gt":
+        return Q(rank__range=(rank - EPSILON, rank + EPSILON), id__lt=cursor[1]) | Q(
+            rank__gt=rank + EPSILON
+        )
+    return Q(rank__range=(rank - EPSILON, rank + EPSILON), id__gt=cursor[1]) | Q(
+        rank__lt=rank - EPSILON
+    )
+
+
 def _prepare_filter_expression(
     field_name: str,
     index: int,
@@ -80,6 +105,10 @@ def _prepare_filter(
                 ('first_field', 'first_value_form_cursor'))
         )
     """
+    if sorting_fields == ["rank", "id"]:
+        # Fast path for filtering by rank
+        return _prepare_filter_by_rank_expression(cursor, sorting_direction)
+
     filter_kwargs = Q()
     for index, field_name in enumerate(sorting_fields):
         if cursor[index] is None and sorting_direction == "gt":
diff --git a/saleor/graphql/product/sorters.py b/saleor/graphql/product/sorters.py
--- a/saleor/graphql/product/sorters.py
+++ b/saleor/graphql/product/sorters.py
@@ -123,7 +123,7 @@ class Meta:
 
 class ProductOrderField(graphene.Enum):
     NAME = ["name", "slug"]
-    RANK = ["rank"]
+    RANK = ["rank", "id"]
     PRICE = ["min_variants_price_amount", "name", "slug"]
     MINIMAL_PRICE = ["discounted_price_amount", "name", "slug"]
     DATE = ["updated_at", "name", "slug"]
diff --git a/saleor/graphql/utils/sorting.py b/saleor/graphql/utils/sorting.py
--- a/saleor/graphql/utils/sorting.py
+++ b/saleor/graphql/utils/sorting.py
@@ -37,7 +37,7 @@ def sort_queryset_for_connection(iterable, args):
             or get_default_channel_slug_or_graphql_error(),
         )
     elif query and "-rank" in query.order_by:
-        return iterable, {"field": "rank", "direction": "-"}
+        return iterable, {"field": ["rank", "id"], "direction": "-"}
     else:
         iterable, sort_by = sort_queryset_by_default(
             queryset=iterable, reversed=reversed
@@ -72,7 +72,6 @@ def sort_queryset(
 
     sorting_field = sort_by.field
     sorting_attribute = getattr(sort_by, "attribute_id", None)
-
     if sorting_field is not None and sorting_attribute is not None:
         raise GraphQLError(
             "You must provide either `field` or `attributeId` to sort the products."
@@ -90,8 +89,15 @@ def sort_queryset(
     if custom_sort_by:
         queryset = custom_sort_by(queryset, channel_slug=channel_slug)
 
-    sorting_field_value = sorting_fields.value
-    sorting_list = [f"{sorting_direction}{field}" for field in sorting_field_value]
+    if sorting_field_name == "rank":
+        # In rank sorting ID is sorted in opposite direction to rank
+        if sorting_direction == "-":
+            sorting_list = ["-rank", "id"]
+        else:
+            sorting_list = ["rank", "-id"]
+    else:
+        sorting_field_value = sorting_fields.value
+        sorting_list = [f"{sorting_direction}{field}" for field in sorting_field_value]
 
     return queryset.order_by(*sorting_list)
 


Test patch:
diff --git a/saleor/graphql/product/tests/test_product.py b/saleor/graphql/product/tests/test_product.py
--- a/saleor/graphql/product/tests/test_product.py
+++ b/saleor/graphql/product/tests/test_product.py
@@ -3749,14 +3749,24 @@ def test_create_product_with_boolean_attribute(
 
 SEARCH_PRODUCTS_QUERY = """
     query Products(
-        $filters: ProductFilterInput, $sortBy: ProductOrder, $channel: String
+        $filters: ProductFilterInput,
+        $sortBy: ProductOrder,
+        $channel: String,
+        $after: String,
     ) {
-        products(first: 5, filter: $filters, sortBy: $sortBy, channel: $channel) {
+        products(
+            first: 5,
+            filter: $filters,
+            sortBy: $sortBy,
+            channel: $channel,
+            after: $after,
+        ) {
             edges {
                 node {
                     id
                     name
                 }
+                cursor
             }
         }
     }
@@ -3900,6 +3910,44 @@ def test_search_product_by_description_and_name_without_sort_by(
     assert data[2]["node"]["name"] == product_1.name
 
 
+def test_search_product_by_description_and_name_and_use_cursor(
+    user_api_client, product_list, product, channel_USD, category, product_type
+):
+    product.description_plaintext = "red big red product"
+    product.save()
+
+    product_2 = product_list[1]
+    product_2.name = "red product"
+    product_2.save()
+    product_1 = product_list[0]
+    product_1.description_plaintext = "some red product"
+    product_1.save()
+
+    variables = {
+        "filters": {
+            "search": "red",
+        },
+        "channel": channel_USD.slug,
+    }
+    response = user_api_client.post_graphql(SEARCH_PRODUCTS_QUERY, variables)
+    content = get_graphql_content(response)
+    cursor = content["data"]["products"]["edges"][0]["cursor"]
+
+    variables = {
+        "filters": {
+            "search": "red",
+        },
+        "after": cursor,
+        "channel": channel_USD.slug,
+    }
+    response = user_api_client.post_graphql(SEARCH_PRODUCTS_QUERY, variables)
+    content = get_graphql_content(response)
+    data = content["data"]["products"]["edges"]
+
+    assert data[0]["node"]["name"] == product.name
+    assert data[1]["node"]["name"] == product_1.name
+
+
 @freeze_time("2020-03-18 12:00:00")
 def test_create_product_with_rating(
     staff_api_client,