blb-ventures / strawberry-django-plus

Enhanced Strawberry GraphQL integration with Django
MIT License
179 stars 47 forks source link

How to have custom ordering method on gql.django.ordering.order class definition #173

Open insomniac34 opened 1 year ago

insomniac34 commented 1 year ago

Hello!

So, I have a Django model, Invoice, which let's say has two fields, id and amount, a float.

class Invoice(models.Model):
    id = models.PrimaryKey(...)

    @property
    def amount(self) -> float:
        # calculates amount on the fly, returns a float

Likewise, I use Strawberry Django Plus to define a custom Filter class for this (yes I know it's not particularly efficient, but it does work):

@gql.django.filters.filter(Invoice, lookups=True)
class InvoiceFilter:
    id: gql.auto

    amount_range: List[float] | None

    def filter_amount_range(self, queryset: InvoiceManager):
        if self.amount_range is not None and len(self.amount_range) == 2:
            # convert queryset into a list so I can access my aggregate InvoiceManager calculated values
            return ListAsQuerySet([
                invoice
                for invoice in queryset.all()
                if invoice.amount >= self.amount_range[0]
                and invoice.amount <= self.amount_range[1]
            ], model=Invoice)
        return queryset

In the above Filter definition, I am able to define custom filtering logic for the Invoice, and I have access to my custom Django InvoiceManager.

Similarly, I am trying to implement custom sorting logic in my InvoiceOrder class, in the same fashion as the above filter snippet; however I can't find any documentation or examples on how to do this. Here is what I have so far, which is failing with cannot resolve keyword 'amount'. Choices are: 'id'. no matter what I try to return from order_amount.

@gql.django.ordering.order(Invoice)
class InvoiceOrder:
    #this field exists on the underlying Invoice django model class.
    id: gql.auto

    # this field is an @property method on the underlying django model class
    amount: gql.auto

    def order_amount(self, queryset):
        # how do I return an ordering definition here?
        pass

Is there a way for me to define custom sorting logic in a gql.django.ordering.order class?

thank you!

bellini666 commented 1 year ago

Hey @insomniac34 . Unfortunatly not yet. The filtering/ordering code is based on the functionality provided by https://github.com/strawberry-graphql/strawberry-graphql-django and it doesn't provide that.

I'm actually planning on creating a new ordering functionality to solve not only that issue, but also others that are not so apparent. For example, there's no way for you to choose the order of the orderings when defining more than one

insomniac34 commented 1 year ago

Thanks for the clarification @bellini666

cpontvieux-systra commented 1 year ago

I made a working patch for having ordered order-by clauses, but if @bellini666 has something prepared for this, it will be probably better. If you really need this now @insomniac34, tell me and I’ll post my dirty patch here :-)

bellini666 commented 1 year ago

@cpontvieux-systra I did not start working on it yet, I only have what I want in mind and how to execute it =P.

But feel free to post your patch here and we can discuss the implementation :)

cpontvieux-systra commented 1 year ago

My patch is in two steps.

First, I patch the order decorator:

from strawberry_django.ordering import order as _order

ORDER_FIELDS_NAME = 'order'

def order(model):
    def wrapper(cls):
        cls.__annotations__[ORDER_FIELDS_NAME] = list[str]
        setattr(cls, ORDER_FIELDS_NAME, None)
        return _order(model)(cls)
    return wrapper

This will add an order field to the schema of type list[str] to any Order type defined. Maybe the name order is bad chosen, I don’t know.

Then the django field using the order type needs to be patched too (it’s a bigger patch as multiple functions need to be patched):

from functools import partial
from django.db.models import QuerySet
from strawberry.utils.str_converters import TO_KEBAB_CASE_RE as TO_SNAKE_CASE_RE
from strawberry_django.fields.field import StrawberryDjangoField
from strawberry_django.ordering import Ordering
from strawberry_django.utils import fields
from strawberry_django_plus import gql

def ordering_orderby_patch(django_field: StrawberryDjangoField) -> None:
        """
        Allow to use an `order` field in order type to order ascending/descending conditions by name.
        """
        def to_snake_case(name: str) -> str:
            return TO_SNAKE_CASE_RE.sub(r'_\1', name).lower()

        def order_order_args(order, args: set[str]) -> list[str]:
            ordering_value = getattr(order, ORDER_FIELDS_NAME, gql.UNSET)
            if ordering_value is gql.UNSET:
                ordering_value = []
            order_list = [to_snake_case(name) for name in ordering_value]
            ordered_args: list[str] = []
            for field in order_list:
                if field in args:
                    ordered_args.append(field)
                    args.remove(field)
                elif (desc_field := f'-{field}') in args:
                    ordered_args.append(desc_field)
                    args.remove(desc_field)
            ordered_args.extend(args)
            return ordered_args

        def generate_order_args(order, prefix="") -> set[str]:
            args = set()
            for field in fields(order):
                ordering = getattr(order, field.name, gql.UNSET)
                if field.name == ORDER_FIELDS_NAME:
                    continue
                if ordering is gql.UNSET:
                    continue
                if ordering == Ordering.ASC:
                    args.add(f"{prefix}{field.name}")
                elif ordering == Ordering.DESC:
                    args.add(f"-{prefix}{field.name}")
                else:
                    subargs = generate_order_args(ordering, prefix=f"{prefix}{field.name}__")
                    args.update(subargs)
            return args

        def apply_order(self, queryset: QuerySet, order) -> QuerySet:
            if order is gql.UNSET or order is None:
                return queryset
            args = order_order_args(order, generate_order_args(order))
            return queryset.order_by(*args) if args else queryset

        setattr(django_field, 'apply_order', partial(apply_order, django_field))

Then you define your order as usual but use the patched order decorator and you need to patch any strawberry django field:

@order(models.YourModel)
class YourModelOrder:
    name: auto
    priority: auto

@gql.django.type(models.YourModel, order=YourModelOrder)
class YourModel:
    id: auto
    name: auto
    priority: auto

all_yourmodels = gql.django.field()
# it’s ok to patch any gql.django.field, it does not harm if your type/field does not have an order class
ordering_orderby_patch(all_yourmodels)

@gql.type
class Query:
    all_yourmodels: list[YourModel] = all_yourmodels

Then in your query:

query {
    allYourmodels (order: {name: ASC, priority: DESC, order: ["priority", "name"]}) {
        id
        name
        priority
    }
}

Most of this code is already in strawberry_django.ordering. As I say, it’s a dirty patch.

On better way would also to have sorted Ordering attributes before any transformation. I may try to improve that.

I’m also not sure order is that smart of a name. If one have an attribute named order and want to sort against it, it will clash. _order could also be an option as it translates to Order in the graphQL Schema and so the capitalization can make it clear that it’s a different kind.

There is also no checking for the values in order. Exceptions should be raised if a non order attribute is used there.

bellini666 commented 1 year ago

That's an interesting solution, and probably the only way to enforce the requested ordering order currently.

What I was thinking was to actually deprecate the current order and define an ordering. That ordering would receive a list of objects from the current order type, and since graphql doesn't support input type unions, we could use the oneOf directive which is going to be supported on strawberry when this PR gets merged. That way we can enforce the ordering order.

With that I also want to make it easier to define custom resolvers for both ordering and filtering. Currently you have to define a resolve_<attr> field, which can lead to errors if you mistype it. I want to create a decorator to define those for both cases.