Repository: saleor/saleor
Pull request: Add ability to filter by slug.
Adding possibility to filter by slug, to endpoints:
- attributes
- categories
- collections
- menus
- pages
- products
- productTypes
- warehouses


Fixes #9476


# Impact

* [ ] New migrations
* [ ] New/Updated API fields or mutations
* [ ] Deprecated API fields or mutations
* [ ] Removed API types, fields, or mutations
* [ ] Documentation needs to be updated

# Pull Request Checklist

<!-- Please keep this section. It will make the maintainer's life easier. -->

* [ ] Privileged queries and mutations are guarded by proper permission checks
* [ ] Database queries are optimized and the number of queries is constant
* [ ] Database migration files are up to date
* [ ] The changes are tested
* [ ] GraphQL schema and type definitions are up to date
* [ ] Changes are mentioned in the changelog


Problem statement:
Filter by slugs
### What I'm trying to achieve
Filter entities by slugs.

### Use case
Working with external APIs that refer to products by slug, such as CMS, ERP etc.  

### Describe a proposed solution
Add property slugs

``` products(filter: { slugs: [String] } ) ```
``` collections(filter: { slugs: [String] } ) ```
``` categories(filter: { slugs: [String] } ) ```
``` pages(filter: { slugs: [String] } ) ```

Hints from issue discussion:

Commit message: Add filter by slug

Slug filter was added to endpoints:
- products
- collections
- categories
- pages

Patch to review:
diff --git a/saleor/graphql/page/filters.py b/saleor/graphql/page/filters.py
index d6fd66c9424..cdb90665ab0 100644
--- a/saleor/graphql/page/filters.py
+++ b/saleor/graphql/page/filters.py
@@ -1,8 +1,13 @@
 import django_filters
+import graphene
 from django.db.models import Q
 
 from ...page import models
-from ..core.filters import GlobalIDMultipleChoiceFilter, MetadataFilterBase
+from ..core.filters import (
+    GlobalIDMultipleChoiceFilter,
+    ListObjectTypeFilter,
+    MetadataFilterBase,
+)
 from ..core.types import FilterInputObjectType
 from ..utils import resolve_global_ids_to_primary_keys
 from ..utils.filters import filter_by_id
@@ -32,10 +37,15 @@ def filter_page_type_search(qs, _, value):
     return qs.filter(Q(name__trigram_similar=value) | Q(slug__trigram_similar=value))
 
 
+def filter_slug_list(qs, _, values):
+    return qs.filter(slug__in=values)
+
+
 class PageFilter(MetadataFilterBase):
     search = django_filters.CharFilter(method=filter_page_search)
     page_types = GlobalIDMultipleChoiceFilter(method=filter_page_page_types)
     ids = GlobalIDMultipleChoiceFilter(method=filter_by_id(Page))
+    slugs = ListObjectTypeFilter(input_class=graphene.String, method=filter_slug_list)
 
     class Meta:
         model = models.Page
diff --git a/saleor/graphql/page/tests/queries/test_pages.py b/saleor/graphql/page/tests/queries/test_pages.py
index ebb93329e8d..19f595f7540 100644
--- a/saleor/graphql/page/tests/queries/test_pages.py
+++ b/saleor/graphql/page/tests/queries/test_pages.py
@@ -76,6 +76,30 @@ def test_pages_query_with_filter_by_page_type(
     assert content["data"]["pages"]["totalCount"] == 2
 
 
+@pytest.mark.parametrize(
+    "filter_by, pages_count",
+    [
+        ({"slugs": ["test-url-1"]}, 1),
+        ({"slugs": ["test-url-1", "test-url-2"]}, 2),
+        ({"slugs": []}, 2),
+    ],
+)
+def test_pages_with_filtering(filter_by, pages_count, staff_api_client, page_list):
+    # given
+    variables = {"filter": filter_by}
+
+    # when
+    response = staff_api_client.post_graphql(
+        QUERY_PAGES_WITH_FILTER,
+        variables,
+    )
+
+    # then
+    content = get_graphql_content(response)
+    pages_nodes = content["data"]["pages"]["edges"]
+    assert len(pages_nodes) == pages_count
+
+
 def test_pages_query_with_filter_by_ids(
     staff_api_client, permission_manage_pages, page_list, page_list_unpublished
 ):
diff --git a/saleor/graphql/product/filters.py b/saleor/graphql/product/filters.py
index 5b5f67d4416..39b1ab1ef44 100644
--- a/saleor/graphql/product/filters.py
+++ b/saleor/graphql/product/filters.py
@@ -586,6 +586,10 @@ def filter_updated_at_range(qs, _, value):
     return filter_range_field(qs, "updated_at", value)
 
 
+def filter_slug_list(qs, _, values):
+    return qs.filter(slug__in=values)
+
+
 class ProductStockFilterInput(graphene.InputObjectType):
     warehouse_ids = NonNullList(graphene.ID, required=False)
     quantity = graphene.Field(IntRangeInput, required=False)
@@ -628,6 +632,7 @@ class ProductFilter(MetadataFilterBase):
     has_preordered_variants = django_filters.BooleanFilter(
         method=filter_has_preordered_variants
     )
+    slugs = ListObjectTypeFilter(input_class=graphene.String, method=filter_slug_list)
 
     class Meta:
         model = Product
@@ -694,6 +699,7 @@ class CollectionFilter(MetadataFilterBase):
     )
     search = django_filters.CharFilter(method="collection_filter_search")
     ids = GlobalIDMultipleChoiceFilter(field_name="id")
+    slugs = ListObjectTypeFilter(input_class=graphene.String, method=filter_slug_list)
 
     class Meta:
         model = Collection
@@ -717,6 +723,7 @@ def filter_is_published(self, queryset, name, value):
 class CategoryFilter(MetadataFilterBase):
     search = django_filters.CharFilter(method="category_filter_search")
     ids = GlobalIDMultipleChoiceFilter(field_name="id")
+    slugs = ListObjectTypeFilter(input_class=graphene.String, method=filter_slug_list)
 
     class Meta:
         model = Category
diff --git a/saleor/graphql/product/tests/test_category_filtering.py b/saleor/graphql/product/tests/test_category_filtering.py
new file mode 100644
index 00000000000..414edcff3f7
--- /dev/null
+++ b/saleor/graphql/product/tests/test_category_filtering.py
@@ -0,0 +1,68 @@
+import pytest
+
+from ....product.models import Category
+from ...tests.utils import get_graphql_content
+
+
+@pytest.fixture
+def categories_for_filtering():
+    return Category.objects.bulk_create(
+        [
+            Category(
+                name="Category1", slug="category1", lft=0, rght=0, tree_id=0, level=0
+            ),
+            Category(
+                name="Category2", slug="category2", lft=1, rght=2, tree_id=1, level=0
+            ),
+            Category(
+                name="Category3", slug="category3", lft=1, rght=2, tree_id=2, level=0
+            ),
+        ]
+    )
+
+
+QUERY_CATEGORIES_WITH_FILTERING = """
+    query (
+        $filter: CategoryFilterInput
+    ){
+        categories (
+            first: 10, filter: $filter
+        ) {
+            edges {
+                node {
+                    name
+                    slug
+                }
+            }
+        }
+    }
+"""
+
+
+@pytest.mark.parametrize(
+    "filter_by, categories_count",
+    [
+        ({"slugs": ["category1"]}, 1),
+        ({"slugs": ["category2", "category3"]}, 2),
+        ({"slugs": []}, 3),
+    ],
+)
+def test_categories_with_filtering(
+    filter_by,
+    categories_count,
+    staff_api_client,
+    categories_for_filtering,
+):
+    # given
+    variables = {"filter": filter_by}
+
+    # when
+    response = staff_api_client.post_graphql(
+        QUERY_CATEGORIES_WITH_FILTERING,
+        variables,
+    )
+
+    # then
+    content = get_graphql_content(response)
+    categories_nodes = content["data"]["categories"]["edges"]
+    assert len(categories_nodes) == categories_count
diff --git a/saleor/graphql/product/tests/test_collection_filtering_and_sorting_with_channels.py b/saleor/graphql/product/tests/test_collection_filtering_and_sorting_with_channels.py
index be39dc8d8aa..3d6a69bb952 100644
--- a/saleor/graphql/product/tests/test_collection_filtering_and_sorting_with_channels.py
+++ b/saleor/graphql/product/tests/test_collection_filtering_and_sorting_with_channels.py
@@ -294,7 +294,13 @@ def test_collections_with_filtering_without_channel(
 
 @pytest.mark.parametrize(
     "filter_by, collections_count",
-    [({"published": "PUBLISHED"}, 1), ({"published": "HIDDEN"}, 3)],
+    [
+        ({"published": "PUBLISHED"}, 1),
+        ({"published": "HIDDEN"}, 3),
+        ({"slugs": ["collection1"]}, 1),
+        ({"slugs": ["collection2", "collection3"]}, 2),
+        ({"slugs": []}, 4),
+    ],
 )
 def test_collections_with_filtering_with_channel_USD(
     filter_by,
@@ -352,7 +358,11 @@ def test_collections_with_filtering_with_channel_PLN(
 
 @pytest.mark.parametrize(
     "filter_by",
-    [{"published": "PUBLISHED"}, {"published": "HIDDEN"}],
+    [
+        {"published": "PUBLISHED"},
+        {"published": "HIDDEN"},
+        {"slugs": ["collection1"]},
+    ],
 )
 def test_collections_with_filtering_and_not_existing_channel(
     filter_by,
diff --git a/saleor/graphql/product/tests/test_product_filtering_and_sorting_with_channels.py b/saleor/graphql/product/tests/test_product_filtering_and_sorting_with_channels.py
index f92eb73c807..bcda88b6b72 100644
--- a/saleor/graphql/product/tests/test_product_filtering_and_sorting_with_channels.py
+++ b/saleor/graphql/product/tests/test_product_filtering_and_sorting_with_channels.py
@@ -518,6 +518,9 @@ def test_products_with_filtering_without_channel(
         ({"price": {"gte": 11}}, 1),
         ({"minimalPrice": {"lte": 4}}, 1),
         ({"minimalPrice": {"gte": 5}}, 3),
+        ({"slugs": ["prod1"]}, 1),
+        ({"slugs": ["prod_prod1", "prod_prod2"]}, 2),
+        ({"slugs": []}, 4),
     ],
 )
 def test_products_with_filtering_with_channel_USD(
@@ -590,6 +593,7 @@ def test_products_with_filtering_with_channel_PLN(
         {"price": {"gte": 11}},
         {"minimalPrice": {"lte": 4}},
         {"minimalPrice": {"gte": 5}},
+        {"slugs": ["prod1"]},
     ],
 )
 def test_products_with_filtering_and_not_existing_channel(
diff --git a/saleor/graphql/schema.graphql b/saleor/graphql/schema.graphql
index 972543f0f89..a0edafe4766 100644
--- a/saleor/graphql/schema.graphql
+++ b/saleor/graphql/schema.graphql
@@ -6005,6 +6005,7 @@ input ProductFilterInput {
   giftCard: Boolean
   ids: [ID!]
   hasPreorderedVariants: Boolean
+  slugs: [String!]
 
   """
   Specifies the channel by which the data should be filtered. 
@@ -9674,6 +9675,7 @@ input CategoryFilterInput {
   search: String
   metadata: [MetadataFilter!]
   ids: [ID!]
+  slugs: [String!]
 }
 
 input CategorySortingInput {
@@ -9707,6 +9709,7 @@ input CollectionFilterInput {
   search: String
   metadata: [MetadataFilter!]
   ids: [ID!]
+  slugs: [String!]
 
   """
   Specifies the channel by which the data should be filtered. 
@@ -9903,6 +9906,7 @@ input PageFilterInput {
   metadata: [MetadataFilter!]
   pageTypes: [ID!]
   ids: [ID!]
+  slugs: [String!]
 }
 
 type PageTypeCountableConnection {


Code review comments:
diff_hunk: @@ -32,10 +37,15 @@ def filter_page_type_search(qs, _, value):
     return qs.filter(Q(name__trigram_similar=value) | Q(slug__trigram_similar=value))
 
 
+def filter_slug_list(qs, _, values):
path: saleor/graphql/page/filters.py
text: @user1:
I think, you can move this function to: `saleor/graphql/core/filters.py` and re-use it in all your filters