Skip to content

Commit

Permalink
Merge pull request #4740 from NyanKiyoshi/product-admin/dynamic-sorting
Browse files Browse the repository at this point in the history
Implement product sorting by attribute
  • Loading branch information
maarcingebala committed Sep 25, 2019
2 parents 5e4e4a4 + 11fe2e3 commit 83c0435
Show file tree
Hide file tree
Showing 9 changed files with 653 additions and 10 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ All notable, unreleased changes to this project will be documented in this file.
- Added validations for minimum password length in settings - #4735 by @fowczarek
- Add error codes to mutations responses - #4676 by @Kwaidan00
- Payment gateways are now saleor plugins with dynamic configuration - #4669 by @salwator
- Added support for sorting product by their attribute values through given attribute ID - #4740 by @NyanKiyoshi

- Unified MenuItemMove to other reordering mutations. It now uses relative positions instead of absolute ones (breaking change) - #4734 by @NyanKiyoshi.
## 2.8.0
Expand Down
32 changes: 31 additions & 1 deletion saleor/graphql/product/resolvers.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
from typing import TYPE_CHECKING, Optional

import graphene
import graphene_django_optimizer as gql_optimizer
from django.db.models import Q, Sum
from graphql import GraphQLError

from ...order import OrderStatus
from ...product import models
from ...search.backends import picker
from ..core.utils import from_global_id_strict_type
from ..utils import filter_by_period, filter_by_query_param, get_database_id, get_nodes
from .enums import AttributeSortField, OrderDirection
from .filters import (
Expand All @@ -17,6 +21,9 @@
sort_qs,
)

if TYPE_CHECKING:
from ..product.types import ProductOrder # noqa

PRODUCT_SEARCH_FIELDS = ("name", "description")
PRODUCT_TYPE_SEARCH_FIELDS = ("name",)
CATEGORY_SEARCH_FIELDS = ("name", "slug", "description", "parent__name")
Expand Down Expand Up @@ -102,6 +109,29 @@ def resolve_digital_contents(info):
return gql_optimizer.query(qs, info)


def sort_products(qs: models.ProductsQueryset, sort_by: Optional["ProductOrder"]):
if sort_by is None:
return qs

# Check if one of the required fields was provided
if sort_by.field and sort_by.attribute_id:
raise GraphQLError(
("You must provide either `field` or `attributeId` to sort the products.")
)

direction = sort_by.direction
sorting_field = sort_by.field

if sort_by.attribute_id:
is_ascending = direction == OrderDirection.ASC
attribute_pk = from_global_id_strict_type(sort_by.attribute_id, "Attribute")
qs = qs.sort_by_attribute(attribute_pk, ascending=is_ascending)
else:
qs = qs.order_by(f"{direction}{sorting_field}")

return qs


def resolve_products(
info,
attributes=None,
Expand All @@ -119,6 +149,7 @@ def resolve_products(

user = info.context.user
qs = models.Product.objects.visible_to_user(user)
qs = sort_products(qs, sort_by)

if query:
search = picker.pick_backend()
Expand All @@ -140,7 +171,6 @@ def resolve_products(

qs = filter_products_by_price(qs, price_lte, price_gte)
qs = filter_products_by_minimal_price(qs, minimal_price_lte, minimal_price_gte)
qs = sort_qs(qs, sort_by)
qs = qs.distinct()

return gql_optimizer.query(qs, info)
Expand Down
11 changes: 8 additions & 3 deletions saleor/graphql/product/types/products.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,9 +127,14 @@ def resolve_attribute_list(

class ProductOrder(graphene.InputObjectType):
field = graphene.Argument(
ProductOrderField,
required=True,
description="Sort products by the selected field.",
ProductOrderField, description="Sort products by the selected field."
)
attribute_id = graphene.Argument(
graphene.ID,
description=(
"Sort product by the selected attribute's values.\n"
"Note: this doesn't take translations into account yet."
),
)
direction = graphene.Argument(
OrderDirection,
Expand Down
3 changes: 2 additions & 1 deletion saleor/graphql/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -2979,7 +2979,8 @@ input ProductInput {
}

input ProductOrder {
field: ProductOrderField!
field: ProductOrderField
attributeId: ID
direction: OrderDirection!
}

Expand Down
80 changes: 78 additions & 2 deletions saleor/product/models.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
from decimal import Decimal
from typing import Iterable
from typing import Iterable, Union
from uuid import uuid4

from django.conf import settings
from django.contrib.postgres.aggregates import StringAgg
from django.contrib.postgres.fields import JSONField
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import F, Q
from django.db.models import Case, Count, F, FilteredRelation, Q, When
from django.urls import reverse
from django.utils.encoding import smart_text
from django.utils.html import strip_tags
Expand Down Expand Up @@ -157,6 +158,81 @@ def collection_sorted(self, user):
)
return qs

def sort_by_attribute(self, attribute_pk: Union[int, str], ascending: bool = True):
"""Sort a query set by the values of the given product attribute.
:param attribute_pk: The database ID (must be a number) of the attribute
to sort by.
:param ascending: The sorting direction.
"""
qs: models.QuerySet = self

# Retrieve all the products' attribute data IDs (assignments) and
# product types that have the given attribute associated to them
attribute_associations, product_types_associated_to_attribute = zip(
*AttributeProduct.objects.filter(attribute_id=attribute_pk).values_list(
"pk", "product_type_id"
)
)

qs = qs.annotate(
# Contains to retrieve the attribute data (singular) of each product
# Refer to `AttributeProduct`.
filtered_attribute=FilteredRelation(
relation_name="attributes",
condition=Q(attributes__assignment_id__in=attribute_associations),
),
# Implicit `GROUP BY` required for the `StringAgg` aggregation
grouped_ids=Count("id"),
# String aggregation of the attribute's values to efficiently sort them
concatenated_values=Case(
# If the product has no association data but has the given attribute
# associated to its product type, then consider the concatenated values
# as empty (non-null).
When(
Q(product_type_id__in=product_types_associated_to_attribute)
& Q(filtered_attribute=None),
then=models.Value(""),
),
default=StringAgg(
F("filtered_attribute__values__name"),
delimiter=",",
ordering=(
[
f"filtered_attribute__values__{field_name}"
for field_name in AttributeValue._meta.ordering
]
),
),
output_field=models.CharField(),
),
)

qs = qs.extra(
order_by=[
Case(
# Make the products having no such attribute be last in the sorting
When(concatenated_values=None, then=2),
# Put the products having an empty attribute value at the bottom of
# the other products.
When(concatenated_values="", then=1),
# Put the products having an attribute value to be always at the top
default=0,
output_field=models.IntegerField(),
),
# Sort each group of products (0, 1, 2, ...) per attribute values
"concatenated_values",
# Sort each group of products by name,
# if they have the same values or not values
"name",
]
)

# Descending sorting
if not ascending:
return qs.reverse()
return qs


class Product(SeoModel, ModelWithMetadata, PublishableModel):
product_type = models.ForeignKey(
Expand Down
24 changes: 24 additions & 0 deletions tests/api/benchmark/test_product.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,27 @@ def test_product_details(product, api_client, count_queries):

variables = {"id": Node.to_global_id("Product", product.pk)}
get_graphql_content(api_client.post_graphql(query, variables))


@pytest.mark.django_db
@pytest.mark.count_queries(autouse=False)
def test_retrieve_product_attributes(product_list, api_client, count_queries):
query = """
query($sortBy: ProductOrder) {
products(first: 10, sortBy: $sortBy) {
edges {
node {
id
attributes {
attribute {
id
}
}
}
}
}
}
"""

variables = {}
get_graphql_content(api_client.post_graphql(query, variables))
2 changes: 1 addition & 1 deletion tests/api/test_product_sorting.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import graphene

from tests.api.utils import get_graphql_content
from ..api.utils import get_graphql_content

GET_SORTED_PRODUCTS_COLLECTION_QUERY = """
query CollectionProducts($id: ID!) {
Expand Down

0 comments on commit 83c0435

Please sign in to comment.