From b4e34a5794edd430f25048c7665e689ab0c085b4 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sat, 9 May 2020 12:28:03 +0100 Subject: [PATCH] Improve DjangoListField (#929) --- docs/fields.rst | 83 +++++++++++++ docs/filtering.rst | 2 +- docs/index.rst | 1 + docs/queries.rst | 4 + graphene_django/__init__.py | 9 +- graphene_django/fields.py | 26 ++-- graphene_django/tests/test_fields.py | 179 ++++++++++++++++++++++++++- 7 files changed, 290 insertions(+), 14 deletions(-) create mode 100644 docs/fields.rst diff --git a/docs/fields.rst b/docs/fields.rst new file mode 100644 index 0000000..1a8afc3 --- /dev/null +++ b/docs/fields.rst @@ -0,0 +1,83 @@ +Fields +====== + +Graphene-Django provides some useful fields to help integrate Django with your GraphQL +Schema. + +DjangoListField +--------------- + +``DjangoListField`` allows you to define a list of :ref:`DjangoObjectType`'s. By default it will resolve the default queryset of the Django model. + +.. code:: python + + from graphene import ObjectType, Schema + from graphene_django import DjangoListField + + class RecipeType(DjangoObjectType): + class Meta: + model = Recipe + fields = ("title", "instructions") + + class Query(ObjectType): + recipes = DjangoListField(RecipeType) + + schema = Schema(query=Query) + +The above code results in the following schema definition: + +.. code:: + + schema { + query: Query + } + + type Query { + recipes: [RecipeType!] + } + + type RecipeType { + title: String! + instructions: String! + } + +Custom resolvers +**************** + +If your ``DjangoObjectType`` has defined a custom +:ref:`get_queryset` method, when resolving a +``DjangoListField`` it will be called with either the return of the field +resolver (if one is defined) or the default queryeset from the Django model. + +For example the following schema will only resolve recipes which have been +published and have a title: + +.. code:: python + + from graphene import ObjectType, Schema + from graphene_django import DjangoListField + + class RecipeType(DjangoObjectType): + class Meta: + model = Recipe + fields = ("title", "instructions") + + @classmethod + def get_queryset(cls, queryset, info): + # Filter out recipes that have no title + return queryset.exclude(title__exact="") + + class Query(ObjectType): + recipes = DjangoListField(RecipeType) + + def resolve_recipes(parent, info): + # Only get recipes that have been published + return Recipe.objects.filter(published=True) + + schema = Schema(query=Query) + + +DjangoConnectionField +--------------------- + +*TODO* diff --git a/docs/filtering.rst b/docs/filtering.rst index 0d37f46..dbbab9d 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -1,7 +1,7 @@ Filtering ========= -Graphene integrates with +Graphene-Django integrates with `django-filter `__ (2.x for Python 3 or 1.x for Python 2) to provide filtering of results. See the `usage documentation `__ diff --git a/docs/index.rst b/docs/index.rst index 93f37db..f4f718c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -25,6 +25,7 @@ For more advanced use, check out the Relay tutorial. tutorial-relay schema queries + fields extra-types mutations filtering diff --git a/docs/queries.rst b/docs/queries.rst index 36cdab1..4b3f718 100644 --- a/docs/queries.rst +++ b/docs/queries.rst @@ -1,3 +1,5 @@ +.. _queries-objecttypes: + Queries & ObjectTypes ===================== @@ -205,6 +207,8 @@ need to create the most basic class for this to work: class Meta: model = Category +.. _django-objecttype-get-queryset: + Default QuerySet ----------------- diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 38f8d8a..62318e9 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,6 +1,11 @@ +from .fields import DjangoConnectionField, DjangoListField from .types import DjangoObjectType -from .fields import DjangoConnectionField __version__ = "2.9.1" -__all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] +__all__ = [ + "__version__", + "DjangoObjectType", + "DjangoListField", + "DjangoConnectionField", +] diff --git a/graphene_django/fields.py b/graphene_django/fields.py index fb6b98a..7539cf2 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -38,16 +38,21 @@ class DjangoListField(Field): def model(self): return self._underlying_type._meta.model + def get_default_queryset(self): + return self.model._default_manager.get_queryset() + @staticmethod - def list_resolver(django_object_type, resolver, root, info, **args): + def list_resolver( + django_object_type, resolver, default_queryset, root, info, **args + ): queryset = maybe_queryset(resolver(root, info, **args)) if queryset is None: - # Default to Django Model queryset - # N.B. This happens if DjangoListField is used in the top level Query object - model_manager = django_object_type._meta.model.objects - queryset = maybe_queryset( - django_object_type.get_queryset(model_manager, info) - ) + queryset = default_queryset + + if isinstance(queryset, QuerySet): + # Pass queryset to the DjangoObjectType get_queryset method + queryset = maybe_queryset(django_object_type.get_queryset(queryset, info)) + return queryset def get_resolver(self, parent_resolver): @@ -55,7 +60,12 @@ class DjangoListField(Field): if isinstance(_type, NonNull): _type = _type.of_type django_object_type = _type.of_type.of_type - return partial(self.list_resolver, django_object_type, parent_resolver) + return partial( + self.list_resolver, + django_object_type, + parent_resolver, + self.get_default_queryset(), + ) class DjangoConnectionField(ConnectionField): diff --git a/graphene_django/tests/test_fields.py b/graphene_django/tests/test_fields.py index 67b3a35..cd5bd1b 100644 --- a/graphene_django/tests/test_fields.py +++ b/graphene_django/tests/test_fields.py @@ -1,4 +1,5 @@ import datetime +from django.db.models import Count import pytest @@ -141,13 +142,26 @@ class TestDjangoListField: pub_date_time=datetime.datetime.now(), editor=r1, ) + ArticleModel.objects.create( + headline="Not so good news", + reporter=r1, + pub_date=datetime.date.today(), + pub_date_time=datetime.datetime.now(), + editor=r1, + ) result = schema.execute(query) assert not result.errors assert result.data == { "reporters": [ - {"firstName": "Tara", "articles": [{"headline": "Amazing news"}]}, + { + "firstName": "Tara", + "articles": [ + {"headline": "Amazing news"}, + {"headline": "Not so good news"}, + ], + }, {"firstName": "Debra", "articles": []}, ] } @@ -163,8 +177,8 @@ class TestDjangoListField: model = ReporterModel fields = ("first_name", "articles") - def resolve_reporters(reporter, info): - return reporter.articles.all() + def resolve_articles(reporter, info): + return reporter.articles.filter(headline__contains="Amazing") class Query(ObjectType): reporters = DjangoListField(Reporter) @@ -192,6 +206,13 @@ class TestDjangoListField: pub_date_time=datetime.datetime.now(), editor=r1, ) + ArticleModel.objects.create( + headline="Not so good news", + reporter=r1, + pub_date=datetime.date.today(), + pub_date_time=datetime.datetime.now(), + editor=r1, + ) result = schema.execute(query) @@ -202,3 +223,155 @@ class TestDjangoListField: {"firstName": "Debra", "articles": []}, ] } + + def test_get_queryset_filter(self): + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ("first_name", "articles") + + @classmethod + def get_queryset(cls, queryset, info): + # Only get reporters with at least 1 article + return queryset.annotate(article_count=Count("articles")).filter( + article_count__gt=0 + ) + + class Query(ObjectType): + reporters = DjangoListField(Reporter) + + def resolve_reporters(_, info): + return ReporterModel.objects.all() + + schema = Schema(query=Query) + + query = """ + query { + reporters { + firstName + } + } + """ + + r1 = ReporterModel.objects.create(first_name="Tara", last_name="West") + ReporterModel.objects.create(first_name="Debra", last_name="Payne") + + ArticleModel.objects.create( + headline="Amazing news", + reporter=r1, + pub_date=datetime.date.today(), + pub_date_time=datetime.datetime.now(), + editor=r1, + ) + + result = schema.execute(query) + + assert not result.errors + assert result.data == {"reporters": [{"firstName": "Tara"},]} + + def test_resolve_list(self): + """Resolving a plain list should work (and not call get_queryset)""" + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ("first_name", "articles") + + @classmethod + def get_queryset(cls, queryset, info): + # Only get reporters with at least 1 article + return queryset.annotate(article_count=Count("articles")).filter( + article_count__gt=0 + ) + + class Query(ObjectType): + reporters = DjangoListField(Reporter) + + def resolve_reporters(_, info): + return [ReporterModel.objects.get(first_name="Debra")] + + schema = Schema(query=Query) + + query = """ + query { + reporters { + firstName + } + } + """ + + r1 = ReporterModel.objects.create(first_name="Tara", last_name="West") + ReporterModel.objects.create(first_name="Debra", last_name="Payne") + + ArticleModel.objects.create( + headline="Amazing news", + reporter=r1, + pub_date=datetime.date.today(), + pub_date_time=datetime.datetime.now(), + editor=r1, + ) + + result = schema.execute(query) + + assert not result.errors + assert result.data == {"reporters": [{"firstName": "Debra"},]} + + def test_get_queryset_foreign_key(self): + class Article(DjangoObjectType): + class Meta: + model = ArticleModel + fields = ("headline",) + + @classmethod + def get_queryset(cls, queryset, info): + # Rose tinted glasses + return queryset.exclude(headline__contains="Not so good") + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ("first_name", "articles") + + class Query(ObjectType): + reporters = DjangoListField(Reporter) + + schema = Schema(query=Query) + + query = """ + query { + reporters { + firstName + articles { + headline + } + } + } + """ + + r1 = ReporterModel.objects.create(first_name="Tara", last_name="West") + ReporterModel.objects.create(first_name="Debra", last_name="Payne") + + ArticleModel.objects.create( + headline="Amazing news", + reporter=r1, + pub_date=datetime.date.today(), + pub_date_time=datetime.datetime.now(), + editor=r1, + ) + ArticleModel.objects.create( + headline="Not so good news", + reporter=r1, + pub_date=datetime.date.today(), + pub_date_time=datetime.datetime.now(), + editor=r1, + ) + + result = schema.execute(query) + + assert not result.errors + assert result.data == { + "reporters": [ + {"firstName": "Tara", "articles": [{"headline": "Amazing news"},],}, + {"firstName": "Debra", "articles": []}, + ] + }