strawberry-graphql / strawberry-django

Strawberry GraphQL Django extension
MIT License
394 stars 115 forks source link

Exposing a list of interface implementations #369

Closed Mapiarz closed 10 months ago

Mapiarz commented 10 months ago

Hi! I'm trying to expose a field (paginated connection) which returns a list of interface implementations. Basically pattern shown here: https://strawberry.rocks/docs/types/interfaces

In my specific case, I'm dealing with a stream of activities. There are some common fields that all activities have, like a timestamp but then there are fields specific to activities of a particular kind. For example a BookingActivity will have a relation to a booking object whereas TaskActivity will have a relation to a user (assignee). Sometimes I'm interested in looking at only BookingActivities or only at TaskActivities but sometimes I'm also interested in looking at all activities across types.

In Django, to achieve the above, usually you create a single model with a field that denotes the 'type' (an enum or something) and a bunch of nullable fields and then have various proxy classes that expose properties/methods that make sense to that particular kind of entity. That's exactly what I'm doing.

So how do I expose that in graphql? I was able to get pretty far by utilizing is_of_type. Take a look:

ActivityType = strawberry.enum(models.ActivityType)

@strawberry_django.interface(models.Activity)
class Activity(strawberry.relay.Node):
    created_by: User
    type: ActivityType
    timestamp: datetime.datetime

@strawberry_django.type(models.Activity)
class BookingActivity(Activity):
    booking: models.Booking

    @classmethod
    def is_type_of(cls, obj: Any, _) -> bool:
        return isinstance(obj, models.Activity) and getattr(obj, "type") == 0

@strawberry_django.type(models.Activity)
class TaskActivity(Activity):
    task: models.Task

    @classmethod
    def is_type_of(cls, obj: Any, _) -> bool:
        return isinstance(obj, models.Activity) and getattr(obj, "type") == 1

Cool, now I can expose the connection field:

    @strawberry_django.connection(
        strawberry_django.relay.ListConnectionWithTotalCount[models.Activity]
    )
    def all_activities(self, info) -> list[Activity]:
        # Can add filters/ordering to this field as needed
        base_qs = floatist_models.Activity.objects.all()
        return cast(list[Activity], base_qs)

The above mostly works. I can query the allActivities field and switch on the type and select appropriate subset of fields. I can also add ordering/filtering if desired.

The problem is that the optimizer doesn't seem to know what to do. There are 2 primary problems:

  1. I need the field 'type' from Activity to determine the actual node type. If I do not select 'type' in my query, the optimizer will cause that field not to be retrieved and thus will lead to N+1 on every single row.
  2. Optimizer only knows how to optimize the fields on the interface, but not on the implementing nodes. For example if I select 'user' from Activity, the optimizer is able to prefetch the users correctly. But if I select booking or task, I get N+1.

Problem (1) could be easily worked around, I suppose. I could inject the 'type' field selection in my resolver (probably) and be done with it.

Problem (2) is not so simple... I don't know how difficult it would be to make optimizer do the right thing in this particular scenario. Which leads me to another problem...

Given that optimizer falls apart in my use case, I wanted to disable the optimizer and optimize everything myself, based on the selections, in the field resolver. Not perfect, but this is a very specific use case and I'd be fine with it. The issue is that the optimizer canno be disabled on the interface. The argument is not exposed (I will submit a PR for that in a moment). Furthermore, annotating the all_activities field with disable_optimization=True doesn't seem to do anything. Lastly, this in my resolver:

with optimizer.DjangoOptimizerExtension.disabled():
    return cast(list[models.Activity], base_qs)

doesn't do anything either.

For the time being the only option for me is to disable optimizer globally or return a list from my field resolver which robs me of the built in support for filtering and ordering.

Please advise what could be done better. I appreciate all feedback.

Upvote & Fund

Fund with Polar

bellini666 commented 10 months ago

Fixed by https://github.com/strawberry-graphql/strawberry-graphql-django/pull/370