From 9d245287a4bd8a001df54cdbfc14f04e0e0d293a Mon Sep 17 00:00:00 2001 From: A C SREEDHAR REDDY Date: Fri, 16 Aug 2019 19:03:59 +0530 Subject: [PATCH 001/116] is_authenticated is bool not callable. (#749) --- docs/authorization.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/authorization.rst b/docs/authorization.rst index 2c38fa4..5199081 100644 --- a/docs/authorization.rst +++ b/docs/authorization.rst @@ -84,7 +84,7 @@ with the context argument. def resolve_my_posts(self, info): # context will reference to the Django request - if not info.context.user.is_authenticated(): + if not info.context.user.is_authenticated: return Post.objects.none() else: return Post.objects.filter(owner=info.context.user) From 1b8184ece14689d33c613092db49bf57206bbd6b Mon Sep 17 00:00:00 2001 From: A C SREEDHAR REDDY Date: Fri, 16 Aug 2019 19:04:28 +0530 Subject: [PATCH 002/116] make Mutation class ObjectType. (#748) --- docs/mutations.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mutations.rst b/docs/mutations.rst index 362df58..aef32eb 100644 --- a/docs/mutations.rst +++ b/docs/mutations.rst @@ -44,7 +44,7 @@ Simple example return QuestionMutation(question=question) - class Mutation: + class Mutation(graphene.ObjectType): update_question = QuestionMutation.Field() From ac79b38cf0e51183c624b403379904cf91f2b161 Mon Sep 17 00:00:00 2001 From: Semyon Pupkov Date: Sat, 7 Sep 2019 21:49:41 +0500 Subject: [PATCH 003/116] Use field and exclude in docs instead deprecated attrs (#740) --- docs/authorization.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/authorization.rst b/docs/authorization.rst index 5199081..ebc9795 100644 --- a/docs/authorization.rst +++ b/docs/authorization.rst @@ -20,7 +20,7 @@ Let's use a simple example model. Limiting Field Access --------------------- -To limit fields in a GraphQL query simply use the ``only_fields`` meta attribute. +To limit fields in a GraphQL query simply use the ``fields`` meta attribute. .. code:: python @@ -31,10 +31,10 @@ To limit fields in a GraphQL query simply use the ``only_fields`` meta attribute class PostNode(DjangoObjectType): class Meta: model = Post - only_fields = ('title', 'content') + fields = ('title', 'content') interfaces = (relay.Node, ) -conversely you can use ``exclude_fields`` meta attribute. +conversely you can use ``exclude`` meta attribute. .. code:: python @@ -45,7 +45,7 @@ conversely you can use ``exclude_fields`` meta attribute. class PostNode(DjangoObjectType): class Meta: model = Post - exclude_fields = ('published', 'owner') + exclude = ('published', 'owner') interfaces = (relay.Node, ) Queryset Filtering On Lists @@ -133,7 +133,7 @@ method to your ``DjangoObjectType``. class PostNode(DjangoObjectType): class Meta: model = Post - only_fields = ('title', 'content') + fields = ('title', 'content') interfaces = (relay.Node, ) @classmethod From 254e59c36fa289ddf86b32b528afeae54dfa1bb1 Mon Sep 17 00:00:00 2001 From: Talley Lambert Date: Sat, 7 Sep 2019 14:49:29 -0400 Subject: [PATCH 004/116] Adds variables arg to GraphQLTestCase.query (#699) * add variables arg in GraphQLTestCase.query * update GraphQLTestCase.query docstring and remove type check --- docs/testing.rst | 22 ++++++++++++++++++++++ graphene_django/utils/testing.py | 15 ++++++++++++--- 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/docs/testing.rst b/docs/testing.rst index b111642..031cf6b 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -37,6 +37,28 @@ Usage: # Add some more asserts if you like ... + def test_query_with_variables(self): + response = self.query( + ''' + query myModel($id: Int!){ + myModel(id: $id) { + id + name + } + } + ''', + op_name='myModel', + variables={'id': 1} + ) + + content = json.loads(response.content) + + # This validates the status code and if you get errors + self.assertResponseNoErrors(response) + + # Add some more asserts if you like + ... + def test_some_mutation(self): response = self.query( ''' diff --git a/graphene_django/utils/testing.py b/graphene_django/utils/testing.py index 0fdac7e..5b694b2 100644 --- a/graphene_django/utils/testing.py +++ b/graphene_django/utils/testing.py @@ -24,7 +24,7 @@ class GraphQLTestCase(TestCase): cls._client = Client() - def query(self, query, op_name=None, input_data=None): + def query(self, query, op_name=None, input_data=None, variables=None): """ Args: query (string) - GraphQL query to run @@ -32,7 +32,11 @@ class GraphQLTestCase(TestCase): supply the op_name. For annon queries ("{ ... }"), should be None (default). input_data (dict) - If provided, the $input variable in GraphQL will be set - to this value + to this value. If both ``input_data`` and ``variables``, + are provided, the ``input`` field in the ``variables`` + dict will be overwritten with this value. + variables (dict) - If provided, the "variables" field in GraphQL will be + set to this value. Returns: Response object from client @@ -40,8 +44,13 @@ class GraphQLTestCase(TestCase): body = {"query": query} if op_name: body["operation_name"] = op_name + if variables: + body["variables"] = variables if input_data: - body["variables"] = {"input": input_data} + if variables in body: + body["variables"]["input"] = input_data + else: + body["variables"] = {"input": input_data} resp = self._client.post( self.GRAPHQL_URL, json.dumps(body), content_type="application/json" From 4bbc0824a623c874dd2726b7aed5bdeb563dda3b Mon Sep 17 00:00:00 2001 From: Tyler Kennedy Date: Tue, 17 Sep 2019 12:13:47 -0400 Subject: [PATCH 005/116] Fix a small typo, filerset_class -> filterset_class (#762) --- docs/filtering.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/filtering.rst b/docs/filtering.rst index 7661928..6fe7cab 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -127,7 +127,7 @@ create your own ``FilterSet``. You can pass it directly as follows: all_animals = DjangoFilterConnectionField(AnimalNode, filterset_class=AnimalFilter) -You can also specify the ``FilterSet`` class using the ``filerset_class`` +You can also specify the ``FilterSet`` class using the ``filterset_class`` parameter when defining your ``DjangoObjectType``, however, this can't be used in unison with the ``filter_fields`` parameter: @@ -218,4 +218,4 @@ with this set up, you can now order the users under group: xxx } } - } \ No newline at end of file + } From fea9b5b194c9ec7dc864143b918c73931f652ef4 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Tue, 17 Sep 2019 17:14:18 +0100 Subject: [PATCH 006/116] Extend DjangoListField to use model queryset if none defined (#732) * Fix model property * Only allow DjangoObjectTypes to DjangoListField * Resolve model queryset by default * Add some more tests to check behaviour --- graphene_django/fields.py | 39 ++++-- graphene_django/tests/test_fields.py | 199 +++++++++++++++++++++++++++ 2 files changed, 230 insertions(+), 8 deletions(-) create mode 100644 graphene_django/tests/test_fields.py diff --git a/graphene_django/fields.py b/graphene_django/fields.py index eb1215e..e6daa88 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -1,13 +1,12 @@ from functools import partial from django.db.models.query import QuerySet -from graphene import NonNull - +from graphql_relay.connection.arrayconnection import connection_from_list_slice from promise import Promise -from graphene.types import Field, List +from graphene import NonNull from graphene.relay import ConnectionField, PageInfo -from graphql_relay.connection.arrayconnection import connection_from_list_slice +from graphene.types import Field, List from .settings import graphene_settings from .utils import maybe_queryset @@ -15,19 +14,43 @@ from .utils import maybe_queryset class DjangoListField(Field): def __init__(self, _type, *args, **kwargs): + from .types import DjangoObjectType + + if isinstance(_type, NonNull): + _type = _type.of_type + + assert issubclass( + _type, DjangoObjectType + ), "DjangoListField only accepts DjangoObjectType types" + # Django would never return a Set of None vvvvvvv super(DjangoListField, self).__init__(List(NonNull(_type)), *args, **kwargs) @property def model(self): - return self.type.of_type._meta.node._meta.model + _type = self.type.of_type + if isinstance(_type, NonNull): + _type = _type.of_type + return _type._meta.model @staticmethod - def list_resolver(resolver, root, info, **args): - return maybe_queryset(resolver(root, info, **args)) + def list_resolver(django_object_type, resolver, 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 = django_object_type._meta.model + queryset = maybe_queryset( + django_object_type.get_queryset(model.objects, info) + ) + return queryset def get_resolver(self, parent_resolver): - return partial(self.list_resolver, parent_resolver) + _type = self.type + 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) class DjangoConnectionField(ConnectionField): diff --git a/graphene_django/tests/test_fields.py b/graphene_django/tests/test_fields.py new file mode 100644 index 0000000..f6abf00 --- /dev/null +++ b/graphene_django/tests/test_fields.py @@ -0,0 +1,199 @@ +import datetime + +import pytest + +from graphene import List, NonNull, ObjectType, Schema, String + +from ..fields import DjangoListField +from ..types import DjangoObjectType +from .models import Article as ArticleModel +from .models import Reporter as ReporterModel + + +@pytest.mark.django_db +class TestDjangoListField: + def test_only_django_object_types(self): + class TestType(ObjectType): + foo = String() + + with pytest.raises(AssertionError): + list_field = DjangoListField(TestType) + + def test_non_null_type(self): + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ("first_name",) + + list_field = DjangoListField(NonNull(Reporter)) + + assert isinstance(list_field.type, List) + assert isinstance(list_field.type.of_type, NonNull) + assert list_field.type.of_type.of_type is Reporter + + def test_get_django_model(self): + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ("first_name",) + + list_field = DjangoListField(Reporter) + assert list_field.model is ReporterModel + + def test_list_field_default_queryset(self): + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ("first_name",) + + class Query(ObjectType): + reporters = DjangoListField(Reporter) + + schema = Schema(query=Query) + + query = """ + query { + reporters { + firstName + } + } + """ + + ReporterModel.objects.create(first_name="Tara", last_name="West") + ReporterModel.objects.create(first_name="Debra", last_name="Payne") + + result = schema.execute(query) + + assert not result.errors + assert result.data == { + "reporters": [{"firstName": "Tara"}, {"firstName": "Debra"}] + } + + def test_override_resolver(self): + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ("first_name",) + + class Query(ObjectType): + reporters = DjangoListField(Reporter) + + def resolve_reporters(_, info): + return ReporterModel.objects.filter(first_name="Tara") + + schema = Schema(query=Query) + + query = """ + query { + reporters { + firstName + } + } + """ + + ReporterModel.objects.create(first_name="Tara", last_name="West") + ReporterModel.objects.create(first_name="Debra", last_name="Payne") + + result = schema.execute(query) + + assert not result.errors + assert result.data == {"reporters": [{"firstName": "Tara"}]} + + def test_nested_list_field(self): + class Article(DjangoObjectType): + class Meta: + model = ArticleModel + fields = ("headline",) + + 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, + ) + + result = schema.execute(query) + + assert not result.errors + assert result.data == { + "reporters": [ + {"firstName": "Tara", "articles": [{"headline": "Amazing news"}]}, + {"firstName": "Debra", "articles": []}, + ] + } + + def test_override_resolver_nested_list_field(self): + class Article(DjangoObjectType): + class Meta: + model = ArticleModel + fields = ("headline",) + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ("first_name", "articles") + + def resolve_reporters(reporter, info): + return reporter.articles.all() + + 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, + ) + + result = schema.execute(query) + + assert not result.errors + assert result.data == { + "reporters": [ + {"firstName": "Tara", "articles": [{"headline": "Amazing news"}]}, + {"firstName": "Debra", "articles": []}, + ] + } From 4f21750fc227a0b339c932bdae651c22fe133ba8 Mon Sep 17 00:00:00 2001 From: Gilly Ames Date: Sun, 22 Sep 2019 20:43:46 +0100 Subject: [PATCH 007/116] Upgrade graphiql version to fix history tool (#772) Graphiql has a history tool that allows you to save and label favourites, but this version has a bug (fixed https://github.com/graphql/graphiql/issues/750). This change upgrades to the latest version. --- graphene_django/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/views.py b/graphene_django/views.py index aefe114..d2c8324 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -51,7 +51,7 @@ def instantiate_middleware(middlewares): class GraphQLView(View): - graphiql_version = "0.13.0" + graphiql_version = "0.14.0" graphiql_template = "graphene/graphiql.html" react_version = "16.8.6" From 0962db5aa60db972a48acd08cbe7ea0945357fce Mon Sep 17 00:00:00 2001 From: Mel van Londen Date: Sun, 22 Sep 2019 13:09:57 -0700 Subject: [PATCH 008/116] =?UTF-8?q?Pin=20higher=20version=20of=20graphene?= =?UTF-8?q?=20for=20proper=20graphql-core=20version=20r=E2=80=A6=20(#768)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index bc7dcd3..a3d0b74 100644 --- a/setup.py +++ b/setup.py @@ -57,7 +57,7 @@ setup( packages=find_packages(exclude=["tests"]), install_requires=[ "six>=1.10.0", - "graphene>=2.1.3,<3", + "graphene>=2.1.7,<3", "graphql-core>=2.1.0,<3", "Django>=1.11", "singledispatch>=3.4.0.3", From cd73cab6991940805372fa92d1c7b01ff9f81489 Mon Sep 17 00:00:00 2001 From: rishabh Date: Mon, 23 Sep 2019 01:40:21 +0530 Subject: [PATCH 009/116] converter.py: Fix typo posgres->postgres (#765) Fixes typo for HStoreField and RangeField converters. --- graphene_django/converter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 063d6be..d69c435 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -235,12 +235,12 @@ def convert_postgres_array_to_list(field, registry=None): @convert_django_field.register(HStoreField) @convert_django_field.register(JSONField) -def convert_posgres_field_to_string(field, registry=None): +def convert_postgres_field_to_string(field, registry=None): return JSONString(description=field.help_text, required=not field.null) @convert_django_field.register(RangeField) -def convert_posgres_range_to_string(field, registry=None): +def convert_postgres_range_to_string(field, registry=None): inner_type = convert_django_field(field.base_field) if not isinstance(inner_type, (List, NonNull)): inner_type = type(inner_type) From a64ba65bef7b80b76c0960eb833a4cec83fb1a67 Mon Sep 17 00:00:00 2001 From: Jason Kraus Date: Sun, 22 Sep 2019 13:13:12 -0700 Subject: [PATCH 010/116] convert DRF ChoiceField to Enum (#537) * convert DRF ChoiceField to Enum, also impacts FilePathField * Pep8 fixes * DRF multiple choices field converts to list of enum * apply black formatting --- graphene_django/converter.py | 27 ++++++++++++------- .../rest_framework/serializer_converter.py | 14 +++++++--- .../tests/test_field_converter.py | 19 +++++++++---- 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/graphene_django/converter.py b/graphene_django/converter.py index d69c435..b59c906 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -1,3 +1,4 @@ +from collections import OrderedDict from django.db import models from django.utils.encoding import force_text @@ -39,6 +40,8 @@ def convert_choice_name(name): def get_choices(choices): converted_names = [] + if isinstance(choices, OrderedDict): + choices = choices.items() for value, help_text in choices: if isinstance(help_text, (tuple, list)): for choice in get_choices(help_text): @@ -52,6 +55,19 @@ def get_choices(choices): yield name, value, description +def convert_choices_to_named_enum_with_descriptions(name, choices): + choices = list(get_choices(choices)) + named_choices = [(c[0], c[1]) for c in choices] + named_choices_descriptions = {c[0]: c[2] for c in choices} + + class EnumWithDescriptionsType(object): + @property + def description(self): + return named_choices_descriptions[self.name] + + return Enum(name, list(named_choices), type=EnumWithDescriptionsType) + + def convert_django_field_with_choices( field, registry=None, convert_choices_to_enum=True ): @@ -63,16 +79,7 @@ def convert_django_field_with_choices( if choices and convert_choices_to_enum: meta = field.model._meta name = to_camel_case("{}_{}".format(meta.object_name, field.name)) - choices = list(get_choices(choices)) - named_choices = [(c[0], c[1]) for c in choices] - named_choices_descriptions = {c[0]: c[2] for c in choices} - - class EnumWithDescriptionsType(object): - @property - def description(self): - return named_choices_descriptions[self.name] - - enum = Enum(name, list(named_choices), type=EnumWithDescriptionsType) + enum = convert_choices_to_named_enum_with_descriptions(name, choices) required = not (field.blank or field.null) converted = enum(description=field.help_text, required=required) else: diff --git a/graphene_django/rest_framework/serializer_converter.py b/graphene_django/rest_framework/serializer_converter.py index c419419..caeb7dd 100644 --- a/graphene_django/rest_framework/serializer_converter.py +++ b/graphene_django/rest_framework/serializer_converter.py @@ -4,6 +4,7 @@ from rest_framework import serializers import graphene from ..registry import get_global_registry +from ..converter import convert_choices_to_named_enum_with_descriptions from ..utils import import_single_dispatch from .types import DictType @@ -130,7 +131,6 @@ def convert_serializer_field_to_time(field): @get_graphene_type_from_serializer_field.register(serializers.ListField) def convert_serializer_field_to_list(field, is_input=True): child_type = get_graphene_type_from_serializer_field(field.child) - return (graphene.List, child_type) @@ -145,5 +145,13 @@ def convert_serializer_field_to_jsonstring(field): @get_graphene_type_from_serializer_field.register(serializers.MultipleChoiceField) -def convert_serializer_field_to_list_of_string(field): - return (graphene.List, graphene.String) +def convert_serializer_field_to_list_of_enum(field): + child_type = convert_serializer_field_to_enum(field) + return (graphene.List, child_type) + + +@get_graphene_type_from_serializer_field.register(serializers.ChoiceField) +def convert_serializer_field_to_enum(field): + # enums require a name + name = field.field_name or field.source or "Choices" + return convert_choices_to_named_enum_with_descriptions(name, field.choices) diff --git a/graphene_django/rest_framework/tests/test_field_converter.py b/graphene_django/rest_framework/tests/test_field_converter.py index 6fa4ca8..82f5b63 100644 --- a/graphene_django/rest_framework/tests/test_field_converter.py +++ b/graphene_django/rest_framework/tests/test_field_converter.py @@ -60,8 +60,17 @@ def test_should_url_convert_string(): assert_conversion(serializers.URLField, graphene.String) -def test_should_choice_convert_string(): - assert_conversion(serializers.ChoiceField, graphene.String, choices=[]) +def test_should_choice_convert_enum(): + field = assert_conversion( + serializers.ChoiceField, + graphene.Enum, + choices=[("h", "Hello"), ("w", "World")], + source="word", + ) + assert field._meta.enum.__members__["H"].value == "h" + assert field._meta.enum.__members__["H"].description == "Hello" + assert field._meta.enum.__members__["W"].value == "w" + assert field._meta.enum.__members__["W"].description == "World" def test_should_base_field_convert_string(): @@ -174,7 +183,7 @@ def test_should_file_convert_string(): def test_should_filepath_convert_string(): - assert_conversion(serializers.FilePathField, graphene.String, path="/") + assert_conversion(serializers.FilePathField, graphene.Enum, path="/") def test_should_ip_convert_string(): @@ -189,9 +198,9 @@ def test_should_json_convert_jsonstring(): assert_conversion(serializers.JSONField, graphene.types.json.JSONString) -def test_should_multiplechoicefield_convert_to_list_of_string(): +def test_should_multiplechoicefield_convert_to_list_of_enum(): field = assert_conversion( serializers.MultipleChoiceField, graphene.List, choices=[1, 2, 3] ) - assert field.of_type == graphene.String + assert issubclass(field.of_type, graphene.Enum) From e4cf59ecec5a47c6986d36241d8e87acb25152ff Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sun, 22 Sep 2019 21:14:59 +0100 Subject: [PATCH 011/116] Handle isnull filters differently (#753) * Handle isnull filters differently * Change to rsplit --- graphene_django/filter/tests/test_fields.py | 54 ++++++++++++++++++++- graphene_django/filter/utils.py | 11 ++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index aa6a903..1ffa0f4 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -56,8 +56,6 @@ if DJANGO_FILTER_INSTALLED: model = Pet interfaces = (Node,) - # schema = Schema() - def get_args(field): return field.args @@ -820,6 +818,58 @@ def test_integer_field_filter_type(): ) +def test_other_filter_types(): + class PetType(DjangoObjectType): + class Meta: + model = Pet + interfaces = (Node,) + filter_fields = {"age": ["exact", "isnull", "lt"]} + fields = ("age",) + + class Query(ObjectType): + pets = DjangoFilterConnectionField(PetType) + + schema = Schema(query=Query) + + assert str(schema) == dedent( + """\ + schema { + query: Query + } + + interface Node { + id: ID! + } + + type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String + } + + type PetType implements Node { + age: Int! + id: ID! + } + + type PetTypeConnection { + pageInfo: PageInfo! + edges: [PetTypeEdge]! + } + + type PetTypeEdge { + node: PetType + cursor: String! + } + + type Query { + pets(before: String, after: String, first: Int, last: Int, age: Int, age_Isnull: Boolean, age_Lt: Int): PetTypeConnection + } + """ + ) + + def test_filter_filterset_based_on_mixin(): class ArticleFilterMixin(FilterSet): @classmethod diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index 81efb63..abb03a9 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -18,9 +18,16 @@ def get_filtering_args_from_filterset(filterset_class, type): if name in filterset_class.declared_filters: form_field = filter_field.field else: - field_name = name.split("__", 1)[0] + try: + field_name, filter_type = name.rsplit("__", 1) + except ValueError: + field_name = name + filter_type = None - if hasattr(model, field_name): + # If the filter type is `isnull` then use the filter provided by + # DjangoFilter (a BooleanFilter). + # Otherwise try and get a filter based on the actual model field + if filter_type != "isnull" and hasattr(model, field_name): model_field = model._meta.get_field(field_name) if hasattr(model_field, "formfield"): From 5068ea05c323ad3962fb6d1d8e36c3b2b134e90f Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sun, 22 Sep 2019 21:17:44 +0100 Subject: [PATCH 012/116] v2.6.0 --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 659cc79..7650dd2 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,6 +1,6 @@ from .types import DjangoObjectType from .fields import DjangoConnectionField -__version__ = "2.5.0" +__version__ = "2.6.0" __all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] From 8d95596ffbb10712e4911dae79ecb4703c743a13 Mon Sep 17 00:00:00 2001 From: Jens Diemer Date: Tue, 1 Oct 2019 15:59:52 +0200 Subject: [PATCH 013/116] Note that release information are on github release page (#790) --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 33f71f3..0f1ee77 100644 --- a/README.md +++ b/README.md @@ -101,3 +101,7 @@ To learn more check out the following [examples](examples/): ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) + +## Release Notes + +* See [Releases page on github](https://github.com/graphql-python/graphene-django/releases) From e17582e1a1b8a9b595bf8aca295026db6ecd8c5e Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Fri, 18 Oct 2019 10:12:03 +0100 Subject: [PATCH 014/116] Update stale.yml --- .github/stale.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/stale.yml b/.github/stale.yml index dc90e5a..c9418f6 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,7 +1,7 @@ # Number of days of inactivity before an issue becomes stale -daysUntilStale: 60 +daysUntilStale: 90 # Number of days of inactivity before a stale issue is closed -daysUntilClose: 7 +daysUntilClose: 14 # Issues with these labels will never be considered stale exemptLabels: - pinned From b085b5922a69f1cadfa2bb72519c19dafebb2e39 Mon Sep 17 00:00:00 2001 From: Misha K Date: Fri, 18 Oct 2019 12:38:59 +0200 Subject: [PATCH 015/116] add Django 3.0 to the test matrix (#793) * add Django 3.0 to the test matrix * fix six imports --- .travis.yml | 4 ++++ graphene_django/debug/sql/tracking.py | 2 +- graphene_django/settings.py | 2 +- graphene_django/utils/utils.py | 2 +- tox.ini | 3 +++ 5 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.travis.yml b/.travis.yml index 871d4e3..3531b56 100644 --- a/.travis.yml +++ b/.travis.yml @@ -35,6 +35,8 @@ matrix: env: DJANGO=2.1 - python: 3.6 env: DJANGO=2.2 + - python: 3.6 + env: DJANGO=3.0 - python: 3.6 env: DJANGO=master @@ -46,6 +48,8 @@ matrix: env: DJANGO=2.1 - python: 3.7 env: DJANGO=2.2 + - python: 3.7 + env: DJANGO=3.0 - python: 3.7 env: DJANGO=master diff --git a/graphene_django/debug/sql/tracking.py b/graphene_django/debug/sql/tracking.py index f96583b..8391eac 100644 --- a/graphene_django/debug/sql/tracking.py +++ b/graphene_django/debug/sql/tracking.py @@ -5,7 +5,7 @@ import json from threading import local from time import time -from django.utils import six +import six from django.utils.encoding import force_text from .types import DjangoDebugSQL diff --git a/graphene_django/settings.py b/graphene_django/settings.py index af63890..9a5e8a9 100644 --- a/graphene_django/settings.py +++ b/graphene_django/settings.py @@ -13,9 +13,9 @@ back to the defaults. """ from __future__ import unicode_literals +import six from django.conf import settings from django.test.signals import setting_changed -from django.utils import six try: import importlib # Available in Python 3.1+ diff --git a/graphene_django/utils/utils.py b/graphene_django/utils/utils.py index 47c0c37..c1d3572 100644 --- a/graphene_django/utils/utils.py +++ b/graphene_django/utils/utils.py @@ -1,8 +1,8 @@ import inspect +import six from django.db import models from django.db.models.manager import Manager -from django.utils import six from django.utils.encoding import force_text from django.utils.functional import Promise diff --git a/tox.ini b/tox.ini index a1b599a..e7287ff 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,7 @@ [tox] envlist = py{27,35,36,37}-django{111,20,21,22,master}, + py{36,37}-django30, black,flake8 [travis:env] @@ -9,6 +10,7 @@ DJANGO = 2.0: django20 2.1: django21 2.2: django22 + 3.0: django30 master: djangomaster [testenv] @@ -23,6 +25,7 @@ deps = django20: Django>=2.0,<2.1 django21: Django>=2.1,<2.2 django22: Django>=2.2,<3.0 + django30: Django>=3.0a1,<3.1 djangomaster: https://github.com/django/django/archive/master.zip commands = {posargs:py.test --cov=graphene_django graphene_django examples} From def6b15e5bf6bb0129932b2286938a2fbb45cfca Mon Sep 17 00:00:00 2001 From: Brett Jackson Date: Sat, 19 Oct 2019 14:33:33 -0500 Subject: [PATCH 016/116] Update schema introspection docs to show SCHEMA_INDENT option (#802) * Update schema introspection docs to show indent settings * fix whitespace --- docs/introspection.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/introspection.rst b/docs/introspection.rst index c1d6ede..dea55bd 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -44,7 +44,8 @@ specify the parameters in your settings.py: GRAPHENE = { 'SCHEMA': 'tutorial.quickstart.schema', - 'SCHEMA_OUTPUT': 'data/schema.json' # defaults to schema.json + 'SCHEMA_OUTPUT': 'data/schema.json', # defaults to schema.json, + 'SCHEMA_INDENT': 2, # Defaults to None (displays all data on a single line) } From e51e60209ac7331af669c8bb231c970a75ad1b72 Mon Sep 17 00:00:00 2001 From: Athul Cyriac Ajay Date: Fri, 1 Nov 2019 05:01:31 +0530 Subject: [PATCH 017/116] Updated Tutorial with Highlights (#801) --- docs/schema.py | 58 +++++++++++++++++++++++++++++++++++++ docs/tutorial-plain.rst | 63 ++--------------------------------------- 2 files changed, 61 insertions(+), 60 deletions(-) create mode 100644 docs/schema.py diff --git a/docs/schema.py b/docs/schema.py new file mode 100644 index 0000000..3d9b2fa --- /dev/null +++ b/docs/schema.py @@ -0,0 +1,58 @@ + import graphene + + from graphene_django.types import DjangoObjectType + + from cookbook.ingredients.models import Category, Ingredient + + + class CategoryType(DjangoObjectType): + class Meta: + model = Category + + + class IngredientType(DjangoObjectType): + class Meta: + model = Ingredient + + + class Query(object): + category = graphene.Field(CategoryType, + id=graphene.Int(), + name=graphene.String()) + all_categories = graphene.List(CategoryType) + + + ingredient = graphene.Field(IngredientType, + id=graphene.Int(), + name=graphene.String()) + all_ingredients = graphene.List(IngredientType) + + def resolve_all_categories(self, info, **kwargs): + return Category.objects.all() + + def resolve_all_ingredients(self, info, **kwargs): + return Ingredient.objects.all() + + def resolve_category(self, info, **kwargs): + id = kwargs.get('id') + name = kwargs.get('name') + + if id is not None: + return Category.objects.get(pk=id) + + if name is not None: + return Category.objects.get(name=name) + + return None + + def resolve_ingredient(self, info, **kwargs): + id = kwargs.get('id') + name = kwargs.get('name') + + if id is not None: + return Ingredient.objects.get(pk=id) + + if name is not None: + return Ingredient.objects.get(name=name) + + return None \ No newline at end of file diff --git a/docs/tutorial-plain.rst b/docs/tutorial-plain.rst index 29df56e..c3ee269 100644 --- a/docs/tutorial-plain.rst +++ b/docs/tutorial-plain.rst @@ -417,67 +417,10 @@ Getting single objects So far, we have been able to fetch list of objects and follow relation. But what about single objects? We can update our schema to support that, by adding new query for ``ingredient`` and ``category`` and adding arguments, so we can query for specific objects. +Add the **Highlighted** lines to ``cookbook/ingredients/schema.py`` -.. code:: python - - import graphene - - from graphene_django.types import DjangoObjectType - - from cookbook.ingredients.models import Category, Ingredient - - - class CategoryType(DjangoObjectType): - class Meta: - model = Category - - - class IngredientType(DjangoObjectType): - class Meta: - model = Ingredient - - - class Query(object): - category = graphene.Field(CategoryType, - id=graphene.Int(), - name=graphene.String()) - all_categories = graphene.List(CategoryType) - - - ingredient = graphene.Field(IngredientType, - id=graphene.Int(), - name=graphene.String()) - all_ingredients = graphene.List(IngredientType) - - def resolve_all_categories(self, info, **kwargs): - return Category.objects.all() - - def resolve_all_ingredients(self, info, **kwargs): - return Ingredient.objects.all() - - def resolve_category(self, info, **kwargs): - id = kwargs.get('id') - name = kwargs.get('name') - - if id is not None: - return Category.objects.get(pk=id) - - if name is not None: - return Category.objects.get(name=name) - - return None - - def resolve_ingredient(self, info, **kwargs): - id = kwargs.get('id') - name = kwargs.get('name') - - if id is not None: - return Ingredient.objects.get(pk=id) - - if name is not None: - return Ingredient.objects.get(name=name) - - return None +.. literalinclude:: schema.py + :emphasize-lines: 19-21,25-27,36-58 Now, with the code in place, we can query for single objects. From 3ce44908c9ddfcf6a7b603acc6ee7aefb64ce03c Mon Sep 17 00:00:00 2001 From: Jason Kraus Date: Thu, 28 Nov 2019 02:48:03 -0800 Subject: [PATCH 018/116] =?UTF-8?q?django-filter:=20resolve=20field=20alon?= =?UTF-8?q?g=20with=20lookup=20expression=20to=20pro=E2=80=A6=20(#805)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * django-filter: resolve field along with lookup expression to properly resolve field * bring back django-filter with method test * remove dangling comment * refactor based on better knowledge of django-filters --- graphene_django/filter/utils.py | 23 +++++++---------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index abb03a9..c5f18e2 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -1,5 +1,6 @@ import six +from django_filters.utils import get_model_field from .filterset import custom_filterset_factory, setup_filterset @@ -18,22 +19,12 @@ def get_filtering_args_from_filterset(filterset_class, type): if name in filterset_class.declared_filters: form_field = filter_field.field else: - try: - field_name, filter_type = name.rsplit("__", 1) - except ValueError: - field_name = name - filter_type = None - - # If the filter type is `isnull` then use the filter provided by - # DjangoFilter (a BooleanFilter). - # Otherwise try and get a filter based on the actual model field - if filter_type != "isnull" and hasattr(model, field_name): - model_field = model._meta.get_field(field_name) - - if hasattr(model_field, "formfield"): - form_field = model_field.formfield( - required=filter_field.extra.get("required", False) - ) + model_field = get_model_field(model, filter_field.field_name) + filter_type = filter_field.lookup_expr + if filter_type != "isnull" and hasattr(model_field, "formfield"): + form_field = model_field.formfield( + required=filter_field.extra.get("required", False) + ) # Fallback to field defined on filter if we can't get it from the # model field From a818ec9017c82105d2dcfb605946b890b319fa97 Mon Sep 17 00:00:00 2001 From: Jason Kraus Date: Thu, 28 Nov 2019 02:49:37 -0800 Subject: [PATCH 019/116] replace merge_queryset with resolve_queryset pattern (#796) * replace merge_queryset with resolve_queryset pattern * skip double limit test * Update graphene_django/fields.py Co-Authored-By: Jonathan Kim * yank skipped test * fix bad variable ref * add test for annotations * add test for using queryset with django filters * document ththat one should use defer instead of values with queysets and DjangoObjectTypes --- docs/queries.rst | 7 ++ graphene_django/fields.py | 35 ++++---- graphene_django/filter/fields.py | 68 ++------------- graphene_django/filter/tests/test_fields.py | 96 +++++++++------------ graphene_django/tests/test_query.py | 58 ++++++++++++- 5 files changed, 132 insertions(+), 132 deletions(-) diff --git a/docs/queries.rst b/docs/queries.rst index 67ebb06..36cdab1 100644 --- a/docs/queries.rst +++ b/docs/queries.rst @@ -282,6 +282,13 @@ of Django's ``HTTPRequest`` in your resolve methods, such as checking for authen return Question.objects.none() +DjangoObjectTypes +~~~~~~~~~~~~~~~~~ + +A Resolver that maps to a defined `DjangoObjectType` should only use methods that return a queryset. +Queryset methods like `values` will return dictionaries, use `defer` instead. + + Plain ObjectTypes ----------------- diff --git a/graphene_django/fields.py b/graphene_django/fields.py index e6daa88..47b44f6 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -39,9 +39,9 @@ class DjangoListField(Field): if queryset is None: # Default to Django Model queryset # N.B. This happens if DjangoListField is used in the top level Query object - model = django_object_type._meta.model + model_manager = django_object_type._meta.model.objects queryset = maybe_queryset( - django_object_type.get_queryset(model.objects, info) + django_object_type.get_queryset(model_manager, info) ) return queryset @@ -108,25 +108,13 @@ class DjangoConnectionField(ConnectionField): @classmethod def resolve_queryset(cls, connection, queryset, info, args): + # queryset is the resolved iterable from ObjectType return connection._meta.node.get_queryset(queryset, info) @classmethod - def merge_querysets(cls, default_queryset, queryset): - if default_queryset.query.distinct and not queryset.query.distinct: - queryset = queryset.distinct() - elif queryset.query.distinct and not default_queryset.query.distinct: - default_queryset = default_queryset.distinct() - return queryset & default_queryset - - @classmethod - def resolve_connection(cls, connection, default_manager, args, iterable): - if iterable is None: - iterable = default_manager + def resolve_connection(cls, connection, args, iterable): iterable = maybe_queryset(iterable) if isinstance(iterable, QuerySet): - if iterable.model.objects is not default_manager: - default_queryset = maybe_queryset(default_manager) - iterable = cls.merge_querysets(default_queryset, iterable) _len = iterable.count() else: _len = len(iterable) @@ -150,6 +138,7 @@ class DjangoConnectionField(ConnectionField): resolver, connection, default_manager, + queryset_resolver, max_limit, enforce_first_or_last, root, @@ -177,9 +166,15 @@ class DjangoConnectionField(ConnectionField): ).format(last, info.field_name, max_limit) args["last"] = min(last, max_limit) + # eventually leads to DjangoObjectType's get_queryset (accepts queryset) + # or a resolve_foo (does not accept queryset) iterable = resolver(root, info, **args) - queryset = cls.resolve_queryset(connection, default_manager, info, args) - on_resolve = partial(cls.resolve_connection, connection, queryset, args) + if iterable is None: + iterable = default_manager + # thus the iterable gets refiltered by resolve_queryset + # but iterable might be promise + iterable = queryset_resolver(connection, iterable, info, args) + on_resolve = partial(cls.resolve_connection, connection, args) if Promise.is_thenable(iterable): return Promise.resolve(iterable).then(on_resolve) @@ -192,6 +187,10 @@ class DjangoConnectionField(ConnectionField): parent_resolver, self.connection_type, self.get_manager(), + self.get_queryset_resolver(), self.max_limit, self.enforce_first_or_last, ) + + def get_queryset_resolver(self): + return self.resolve_queryset diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py index 338becb..9943346 100644 --- a/graphene_django/filter/fields.py +++ b/graphene_django/filter/fields.py @@ -52,69 +52,17 @@ class DjangoFilterConnectionField(DjangoConnectionField): return get_filtering_args_from_filterset(self.filterset_class, self.node_type) @classmethod - def merge_querysets(cls, default_queryset, queryset): - # There could be the case where the default queryset (returned from the filterclass) - # and the resolver queryset have some limits on it. - # We only would be able to apply one of those, but not both - # at the same time. - - # See related PR: https://github.com/graphql-python/graphene-django/pull/126 - - assert not ( - default_queryset.query.low_mark and queryset.query.low_mark - ), "Received two sliced querysets (low mark) in the connection, please slice only in one." - assert not ( - default_queryset.query.high_mark and queryset.query.high_mark - ), "Received two sliced querysets (high mark) in the connection, please slice only in one." - low = default_queryset.query.low_mark or queryset.query.low_mark - high = default_queryset.query.high_mark or queryset.query.high_mark - default_queryset.query.clear_limits() - queryset = super(DjangoFilterConnectionField, cls).merge_querysets( - default_queryset, queryset - ) - queryset.query.set_limits(low, high) - return queryset - - @classmethod - def connection_resolver( - cls, - resolver, - connection, - default_manager, - max_limit, - enforce_first_or_last, - filterset_class, - filtering_args, - root, - info, - **args + def resolve_queryset( + cls, connection, iterable, info, args, filtering_args, filterset_class ): filter_kwargs = {k: v for k, v in args.items() if k in filtering_args} - qs = filterset_class( - data=filter_kwargs, - queryset=default_manager.get_queryset(), - request=info.context, + return filterset_class( + data=filter_kwargs, queryset=iterable, request=info.context ).qs - return super(DjangoFilterConnectionField, cls).connection_resolver( - resolver, - connection, - qs, - max_limit, - enforce_first_or_last, - root, - info, - **args - ) - - def get_resolver(self, parent_resolver): + def get_queryset_resolver(self): return partial( - self.connection_resolver, - parent_resolver, - self.connection_type, - self.get_manager(), - self.max_limit, - self.enforce_first_or_last, - self.filterset_class, - self.filtering_args, + self.resolve_queryset, + filterset_class=self.filterset_class, + filtering_args=self.filtering_args, ) diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index 1ffa0f4..1eba601 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -608,58 +608,6 @@ def test_should_query_filter_node_limit(): assert result.data == expected -def test_should_query_filter_node_double_limit_raises(): - class ReporterFilter(FilterSet): - limit = NumberFilter(method="filter_limit") - - def filter_limit(self, queryset, name, value): - return queryset[:value] - - class Meta: - model = Reporter - fields = ["first_name"] - - class ReporterType(DjangoObjectType): - class Meta: - model = Reporter - interfaces = (Node,) - - class Query(ObjectType): - all_reporters = DjangoFilterConnectionField( - ReporterType, filterset_class=ReporterFilter - ) - - def resolve_all_reporters(self, info, **args): - return Reporter.objects.order_by("a_choice")[:2] - - Reporter.objects.create( - first_name="Bob", last_name="Doe", email="bobdoe@example.com", a_choice=2 - ) - Reporter.objects.create( - first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 - ) - - schema = Schema(query=Query) - query = """ - query NodeFilteringQuery { - allReporters(limit: 1) { - edges { - node { - id - firstName - } - } - } - } - """ - - result = schema.execute(query) - assert len(result.errors) == 1 - assert str(result.errors[0]) == ( - "Received two sliced querysets (high mark) in the connection, please slice only in one." - ) - - def test_order_by_is_perserved(): class ReporterType(DjangoObjectType): class Meta: @@ -721,7 +669,7 @@ def test_order_by_is_perserved(): assert reverse_result.data == reverse_expected -def test_annotation_is_perserved(): +def test_annotation_is_preserved(): class ReporterType(DjangoObjectType): full_name = String() @@ -766,6 +714,48 @@ def test_annotation_is_perserved(): assert result.data == expected +def test_annotation_with_only(): + class ReporterType(DjangoObjectType): + full_name = String() + + class Meta: + model = Reporter + interfaces = (Node,) + filter_fields = () + + class Query(ObjectType): + all_reporters = DjangoFilterConnectionField(ReporterType) + + def resolve_all_reporters(self, info, **args): + return Reporter.objects.only("first_name", "last_name").annotate( + full_name=Concat( + "first_name", Value(" "), "last_name", output_field=TextField() + ) + ) + + Reporter.objects.create(first_name="John", last_name="Doe") + + schema = Schema(query=Query) + + query = """ + query NodeFilteringQuery { + allReporters(first: 1) { + edges { + node { + fullName + } + } + } + } + """ + expected = {"allReporters": {"edges": [{"node": {"fullName": "John Doe"}}]}} + + result = schema.execute(query) + + assert not result.errors + assert result.data == expected + + def test_integer_field_filter_type(): class PetType(DjangoObjectType): class Meta: diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index f24f84b..95db2d1 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -638,6 +638,8 @@ def test_should_error_if_first_is_greater_than_max(): class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) + assert Query.all_reporters.max_limit == 100 + r = Reporter.objects.create( first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 ) @@ -679,6 +681,8 @@ def test_should_error_if_last_is_greater_than_max(): class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) + assert Query.all_reporters.max_limit == 100 + r = Reporter.objects.create( first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 ) @@ -804,7 +808,7 @@ def test_should_query_connectionfields_with_manager(): schema = graphene.Schema(query=Query) query = """ query ReporterLastQuery { - allReporters(first: 2) { + allReporters(first: 1) { edges { node { id @@ -1116,3 +1120,55 @@ def test_should_preserve_prefetch_related(django_assert_num_queries): with django_assert_num_queries(3) as captured: result = schema.execute(query) assert not result.errors + + +def test_should_preserve_annotations(): + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (graphene.relay.Node,) + + class FilmType(DjangoObjectType): + reporters = DjangoConnectionField(ReporterType) + reporters_count = graphene.Int() + + class Meta: + model = Film + interfaces = (graphene.relay.Node,) + + class Query(graphene.ObjectType): + films = DjangoConnectionField(FilmType) + + def resolve_films(root, info): + qs = Film.objects.prefetch_related("reporters") + return qs.annotate(reporters_count=models.Count("reporters")) + + r1 = Reporter.objects.create(first_name="Dave", last_name="Smith") + r2 = Reporter.objects.create(first_name="Jane", last_name="Doe") + + f1 = Film.objects.create() + f1.reporters.set([r1, r2]) + f2 = Film.objects.create() + f2.reporters.set([r2]) + + query = """ + query { + films { + edges { + node { + reportersCount + } + } + } + } + """ + schema = graphene.Schema(query=Query) + result = schema.execute(query) + assert not result.errors, str(result) + + expected = { + "films": { + "edges": [{"node": {"reportersCount": 2}}, {"node": {"reportersCount": 1}}] + } + } + assert result.data == expected, str(result.data) From e82a2d75c645989ade5c51b578230f5d313e6a7c Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Thu, 28 Nov 2019 19:23:31 +0000 Subject: [PATCH 020/116] v2.7.0 --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 7650dd2..bc01752 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,6 +1,6 @@ from .types import DjangoObjectType from .fields import DjangoConnectionField -__version__ = "2.6.0" +__version__ = "2.7.0" __all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] From 7e7f18ee0e96562f57628c2cd5cbd9a5a6941f57 Mon Sep 17 00:00:00 2001 From: Thiago Bellini Ribeiro Date: Fri, 29 Nov 2019 06:13:16 -0300 Subject: [PATCH 021/116] Keep original queryset on DjangoFilterConnectionField (#816) * Keep original queryset on DjangoFilterConnectionField The PR #796 broke DjangoFilterConnectionField making it always get the raw queryset from the model to apply the filters in it. This makes sure that the DjangoObjectType's .get_queryset is called, keeping any filtering it might have made. * Add regression test --- graphene_django/filter/fields.py | 7 ++-- graphene_django/filter/tests/test_fields.py | 38 +++++++++++++++++++++ 2 files changed, 42 insertions(+), 3 deletions(-) diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py index 9943346..a46a4b7 100644 --- a/graphene_django/filter/fields.py +++ b/graphene_django/filter/fields.py @@ -55,10 +55,11 @@ class DjangoFilterConnectionField(DjangoConnectionField): def resolve_queryset( cls, connection, iterable, info, args, filtering_args, filterset_class ): + qs = super(DjangoFilterConnectionField, cls).resolve_queryset( + connection, iterable, info, args + ) filter_kwargs = {k: v for k, v in args.items() if k in filtering_args} - return filterset_class( - data=filter_kwargs, queryset=iterable, request=info.context - ).qs + return filterset_class(data=filter_kwargs, queryset=qs, request=info.context).qs def get_queryset_resolver(self): return partial( diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index 1eba601..de366ba 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -756,6 +756,44 @@ def test_annotation_with_only(): assert result.data == expected +def test_node_get_queryset_is_called(): + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + filter_fields = () + + @classmethod + def get_queryset(cls, queryset, info): + return queryset.filter(first_name="b") + + class Query(ObjectType): + all_reporters = DjangoFilterConnectionField( + ReporterType, reverse_order=Boolean() + ) + + Reporter.objects.create(first_name="b") + Reporter.objects.create(first_name="a") + + schema = Schema(query=Query) + query = """ + query NodeFilteringQuery { + allReporters(first: 10) { + edges { + node { + firstName + } + } + } + } + """ + expected = {"allReporters": {"edges": [{"node": {"firstName": "b"}}]}} + + result = schema.execute(query) + assert not result.errors + assert result.data == expected + + def test_integer_field_filter_type(): class PetType(DjangoObjectType): class Meta: From 374d8a8a9e6fd2f2fdd373183f35d75229617768 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Fri, 29 Nov 2019 09:13:36 +0000 Subject: [PATCH 022/116] v2.7.1 --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index bc01752..df58a5a 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,6 +1,6 @@ from .types import DjangoObjectType from .fields import DjangoConnectionField -__version__ = "2.7.0" +__version__ = "2.7.1" __all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] From a73d6532744fac27af049d3aa6fbf7e1acc831fa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 7 Dec 2019 19:56:40 +0000 Subject: [PATCH 023/116] Bump django from 2.2.4 to 2.2.8 in /examples/cookbook-plain (#822) Bumps [django](https://github.com/django/django) from 2.2.4 to 2.2.8. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.2.4...2.2.8) Signed-off-by: dependabot[bot] --- examples/cookbook-plain/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cookbook-plain/requirements.txt b/examples/cookbook-plain/requirements.txt index 802aa37..beed53b 100644 --- a/examples/cookbook-plain/requirements.txt +++ b/examples/cookbook-plain/requirements.txt @@ -1,4 +1,4 @@ graphene graphene-django graphql-core>=2.1rc1 -django==2.2.4 +django==2.2.8 From 968002f1554e3a7a1c0617682be64b67823b2581 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 7 Dec 2019 19:56:57 +0000 Subject: [PATCH 024/116] Bump django from 2.2.4 to 2.2.8 in /examples/cookbook (#821) Bumps [django](https://github.com/django/django) from 2.2.4 to 2.2.8. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.2.4...2.2.8) Signed-off-by: dependabot[bot] --- examples/cookbook/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cookbook/requirements.txt b/examples/cookbook/requirements.txt index 0537103..3209a5e 100644 --- a/examples/cookbook/requirements.txt +++ b/examples/cookbook/requirements.txt @@ -1,5 +1,5 @@ graphene graphene-django graphql-core>=2.1rc1 -django==2.2.4 +django==2.2.8 django-filter>=2 From b66a3f347947804d0ab7d9763309e2977b5bcd5a Mon Sep 17 00:00:00 2001 From: Chibuotu Amadi Date: Thu, 26 Dec 2019 12:45:18 +0100 Subject: [PATCH 025/116] Add headers arg to GraphQLTestCase.query (#827) * Add headers arg to GraphQLTestCase.query * fix headers NoneType case in GraphQLTestCase.query * Run format Co-authored-by: Jonathan Kim --- graphene_django/utils/testing.py | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/graphene_django/utils/testing.py b/graphene_django/utils/testing.py index 5b694b2..8a9b994 100644 --- a/graphene_django/utils/testing.py +++ b/graphene_django/utils/testing.py @@ -24,7 +24,7 @@ class GraphQLTestCase(TestCase): cls._client = Client() - def query(self, query, op_name=None, input_data=None, variables=None): + def query(self, query, op_name=None, input_data=None, variables=None, headers=None): """ Args: query (string) - GraphQL query to run @@ -36,7 +36,9 @@ class GraphQLTestCase(TestCase): are provided, the ``input`` field in the ``variables`` dict will be overwritten with this value. variables (dict) - If provided, the "variables" field in GraphQL will be - set to this value. + set to this value. + headers (dict) - If provided, the headers in POST request to GRAPHQL_URL + will be set to this value. Returns: Response object from client @@ -51,10 +53,17 @@ class GraphQLTestCase(TestCase): body["variables"]["input"] = input_data else: body["variables"] = {"input": input_data} - - resp = self._client.post( - self.GRAPHQL_URL, json.dumps(body), content_type="application/json" - ) + if headers: + resp = self._client.post( + self.GRAPHQL_URL, + json.dumps(body), + content_type="application/json", + **headers + ) + else: + resp = self._client.post( + self.GRAPHQL_URL, json.dumps(body), content_type="application/json" + ) return resp def assertResponseNoErrors(self, resp): From 3d01acf169601c7c644da45c2b608a7c125003f0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Dec 2019 14:25:34 +0000 Subject: [PATCH 026/116] Bump django from 2.2.8 to 3.0 in /examples/cookbook (#825) Bumps [django](https://github.com/django/django) from 2.2.8 to 3.0. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.2.8...3.0) Signed-off-by: dependabot[bot] --- examples/cookbook/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cookbook/requirements.txt b/examples/cookbook/requirements.txt index 3209a5e..b1baa57 100644 --- a/examples/cookbook/requirements.txt +++ b/examples/cookbook/requirements.txt @@ -1,5 +1,5 @@ graphene graphene-django graphql-core>=2.1rc1 -django==2.2.8 +django==3.0 django-filter>=2 From 45df7445f4dd21d08a6bddd084a113b73c957091 Mon Sep 17 00:00:00 2001 From: cbergmiller Date: Fri, 27 Dec 2019 15:26:42 +0100 Subject: [PATCH 027/116] Read csrftoken from DOM if no cookie is set (#826) --- graphene_django/static/graphene_django/graphiql.js | 5 ++++- graphene_django/templates/graphene/graphiql.html | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/graphene_django/static/graphene_django/graphiql.js b/graphene_django/static/graphene_django/graphiql.js index 2be7e3c..e38cd62 100644 --- a/graphene_django/static/graphene_django/graphiql.js +++ b/graphene_django/static/graphene_django/graphiql.js @@ -3,8 +3,11 @@ // Parse the cookie value for a CSRF token var csrftoken; var cookies = ('; ' + document.cookie).split('; csrftoken='); - if (cookies.length == 2) + if (cookies.length == 2) { csrftoken = cookies.pop().split(';').shift(); + } else { + csrftoken = document.querySelector("[name=csrfmiddlewaretoken]").value; + } // Collect the URL parameters var parameters = {}; diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index d0fb5a8..a0d0e1a 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -31,6 +31,7 @@ add "&raw" to the end of the URL within a browser. crossorigin="anonymous"> + {% csrf_token %} From 7940a7b954ef56fa9e538c3d0ff0cecb3ff54d42 Mon Sep 17 00:00:00 2001 From: dan-klasson Date: Fri, 27 Dec 2019 15:46:48 +0100 Subject: [PATCH 028/116] added support for partial updates in serializers (#731) * added support for partial updates in serializers * Add test to verify partial updates Co-authored-by: Jonathan Kim --- graphene_django/rest_framework/mutation.py | 3 +++ graphene_django/rest_framework/tests/test_mutation.py | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py index d9c695e..060b370 100644 --- a/graphene_django/rest_framework/mutation.py +++ b/graphene_django/rest_framework/mutation.py @@ -102,8 +102,10 @@ class SerializerMutation(ClientIDMutation): instance = get_object_or_404( model_class, **{lookup_field: input[lookup_field]} ) + partial = True elif "create" in cls._meta.model_operations: instance = None + partial = False else: raise Exception( 'Invalid update operation. Input parameter "{}" required.'.format( @@ -115,6 +117,7 @@ class SerializerMutation(ClientIDMutation): "instance": instance, "data": input, "context": {"request": info.context}, + "partial": partial, } return {"data": input, "context": {"request": info.context}} diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py index 9d8b950..bfb247d 100644 --- a/graphene_django/rest_framework/tests/test_mutation.py +++ b/graphene_django/rest_framework/tests/test_mutation.py @@ -183,6 +183,16 @@ def test_model_update_mutate_and_get_payload_success(): assert result.cool_name == "New Narf" +@mark.django_db +def test_model_partial_update_mutate_and_get_payload_success(): + instance = MyFakeModel.objects.create(cool_name="Narf") + result = MyModelMutation.mutate_and_get_payload( + None, mock_info(), **{"id": instance.id} + ) + assert result.errors is None + assert result.cool_name == "Narf" + + @mark.django_db def test_model_invalid_update_mutate_and_get_payload_success(): class InvalidModelMutation(SerializerMutation): From f661cf83355fb41e78625ecaae857fc4a609be13 Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Mon, 30 Dec 2019 17:14:41 +0300 Subject: [PATCH 029/116] Fix typo in exclude type checking test (#841) --- graphene_django/tests/test_types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 5e9d1c2..5186623 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -315,7 +315,7 @@ def test_django_objecttype_fields_exclude_type_checking(): class Reporter2(DjangoObjectType): class Meta: model = ReporterModel - fields = "foo" + exclude = "foo" class TestDjangoObjectType: From efe210f8acda0d88a9763a3805959e4e7317b5c3 Mon Sep 17 00:00:00 2001 From: Vyacheslav Matyukhin Date: Tue, 31 Dec 2019 16:55:45 +0300 Subject: [PATCH 030/116] Validate Meta.fields and Meta.exclude on DjangoObjectType (#842) Resolves #840 --- graphene_django/tests/models.py | 3 +++ graphene_django/tests/test_types.py | 24 ++++++++++++++++++++++++ graphene_django/types.py | 18 ++++++++++++++++++ 3 files changed, 45 insertions(+) diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py index 14a8367..44a5d8a 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -64,6 +64,9 @@ class Reporter(models.Model): if self.reporter_type == 2: # quick and dirty way without enums self.__class__ = CNNReporter + def some_method(self): + return 123 + class CNNReporterManager(models.Manager): def get_queryset(self): diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 5186623..cb31a9c 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -318,6 +318,30 @@ def test_django_objecttype_fields_exclude_type_checking(): exclude = "foo" +@with_local_registry +def test_django_objecttype_fields_exclude_exist_on_model(): + with pytest.raises(Exception, match=r"Field .* doesn't exist"): + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ["first_name", "foo", "email"] + + with pytest.raises(Exception, match=r"Field .* doesn't exist"): + + class Reporter2(DjangoObjectType): + class Meta: + model = ReporterModel + exclude = ["first_name", "foo", "email"] + + with pytest.raises(Exception, match=r".* exists on model .* but it's not a field"): + + class Reporter3(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ["first_name", "some_method", "email"] + + class TestDjangoObjectType: @pytest.fixture def PetModel(self): diff --git a/graphene_django/types.py b/graphene_django/types.py index ec426f1..4824c45 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -33,6 +33,24 @@ def construct_fields( ): _model_fields = get_model_fields(model) + # Validate the given fields against the model's fields. + model_field_names = set(field[0] for field in _model_fields) + for fields_list in (only_fields, exclude_fields): + if not fields_list: + continue + for name in fields_list: + if name in model_field_names: + continue + + if hasattr(model, name): + raise Exception( + '"{}" exists on model {} but it\'s not a field.'.format(name, model) + ) + else: + raise Exception( + 'Field "{}" doesn\'t exist on model {}.'.format(name, model) + ) + fields = OrderedDict() for name, field in _model_fields: is_not_in_only = only_fields and name not in only_fields From 3dd04f68ab4b4dab0116b2f0b8d230581b96519d Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Tue, 31 Dec 2019 13:56:04 +0000 Subject: [PATCH 031/116] Update travis config to only run deploy once (#837) --- .travis.yml | 33 +++++++++++++++++++++------------ 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/.travis.yml b/.travis.yml index 3531b56..1718d79 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,8 +12,17 @@ after_success: - pip install coveralls - coveralls -matrix: +stages: + - test + - name: deploy + if: tag IS present + +jobs: fast_finish: true + + allow_failures: + - env: DJANGO=master + include: - python: 2.7 env: DJANGO=1.11 @@ -56,14 +65,14 @@ matrix: - python: 3.7 env: TOXENV=black,flake8 - allow_failures: - - env: DJANGO=master - -deploy: - provider: pypi - user: syrusakbary - on: - tags: true - password: - secure: kymIFCEPUbkgRqe2NAXkWfxMmGRfWvWBOP6LIXdVdkOOkm91fU7bndPGrAjos+/7gN0Org609ZmHSlVXNMJUWcsL2or/x5LcADJ4cZDe+79qynuoRb9xs1Ri4O4SBAuVMZxuVJvs8oUzT2R11ql5vASSMtXgbX+ZDGpmPRVZStkCuXgOc4LBhbPKyl3OFy7UQFPgAEmy3Yjh4ZSKzlXheK+S6mmr60+DCIjpaA0BWPxYK9FUE0qm7JJbHLUbwsUP/QMp5MmGjwFisXCNsIe686B7QKRaiOw62eJc2R7He8AuEC8T9OM4kRwDlecSn8mMpkoSB7QWtlJ+6XdLrJFPNvtrOfgfzS9/96Qrw9WlOslk68hMlhJeRb0s2YUD8tiV3UUkvbL1mfFoS4SI9U+rojS55KhUEJWHg1w7DjoOPoZmaIL2ChRupmvrFYNAGae1cxwG3Urh+t3wYlN3gpKsRDe5GOT7Wm2tr0ad3McCpDGUwSChX59BAJXe/MoLxkKScTrMyR8yMxHOF0b4zpVn5l7xB/o2Ik4zavx5q/0rGBMK2D+5d+gpQogKShoquTPsZUwO7sB5hYeH2hqGqpeGzZtb76E2zZYd18pJ0FsBudm5+KWjYdZ+vbtGrLxdTXJ1EEtzVXm0lscykTpqUucbXSa51dhStJvW2xEEz6p3rHo= - distributions: "sdist bdist_wheel" + - stage: deploy + python: 3.7 + after_success: true + deploy: + provider: pypi + user: syrusakbary + on: + tags: true + password: + secure: kymIFCEPUbkgRqe2NAXkWfxMmGRfWvWBOP6LIXdVdkOOkm91fU7bndPGrAjos+/7gN0Org609ZmHSlVXNMJUWcsL2or/x5LcADJ4cZDe+79qynuoRb9xs1Ri4O4SBAuVMZxuVJvs8oUzT2R11ql5vASSMtXgbX+ZDGpmPRVZStkCuXgOc4LBhbPKyl3OFy7UQFPgAEmy3Yjh4ZSKzlXheK+S6mmr60+DCIjpaA0BWPxYK9FUE0qm7JJbHLUbwsUP/QMp5MmGjwFisXCNsIe686B7QKRaiOw62eJc2R7He8AuEC8T9OM4kRwDlecSn8mMpkoSB7QWtlJ+6XdLrJFPNvtrOfgfzS9/96Qrw9WlOslk68hMlhJeRb0s2YUD8tiV3UUkvbL1mfFoS4SI9U+rojS55KhUEJWHg1w7DjoOPoZmaIL2ChRupmvrFYNAGae1cxwG3Urh+t3wYlN3gpKsRDe5GOT7Wm2tr0ad3McCpDGUwSChX59BAJXe/MoLxkKScTrMyR8yMxHOF0b4zpVn5l7xB/o2Ik4zavx5q/0rGBMK2D+5d+gpQogKShoquTPsZUwO7sB5hYeH2hqGqpeGzZtb76E2zZYd18pJ0FsBudm5+KWjYdZ+vbtGrLxdTXJ1EEtzVXm0lscykTpqUucbXSa51dhStJvW2xEEz6p3rHo= + distributions: "sdist bdist_wheel" From 399ad13a705db243f3add181ef5a5bc95123b506 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Tue, 31 Dec 2019 14:10:18 +0000 Subject: [PATCH 032/116] v2.8.0 --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index df58a5a..1ddc2cb 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,6 +1,6 @@ from .types import DjangoObjectType from .fields import DjangoConnectionField -__version__ = "2.7.1" +__version__ = "2.8.0" __all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] From b8a2d5953a32c3bbd31fd7799271e879384ca000 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Tue, 31 Dec 2019 14:34:47 +0000 Subject: [PATCH 033/116] Don't run tests during deploy stage --- .travis.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.travis.yml b/.travis.yml index 1718d79..bbeeb80 100644 --- a/.travis.yml +++ b/.travis.yml @@ -66,6 +66,7 @@ jobs: env: TOXENV=black,flake8 - stage: deploy + script: skip python: 3.7 after_success: true deploy: From de87573e0c6c5f70c756e4b7eedfd90ba8945593 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sat, 11 Jan 2020 14:49:17 +0100 Subject: [PATCH 034/116] Add information on how to deal with CSRF protection (#838) --- docs/installation.rst | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/docs/installation.rst b/docs/installation.rst index a2dc665..52f2520 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -66,4 +66,26 @@ The most basic ``schema.py`` looks like this: schema = graphene.Schema(query=Query) -To learn how to extend the schema object for your project, read the basic tutorial. \ No newline at end of file +To learn how to extend the schema object for your project, read the basic tutorial. + +CSRF exempt +----------- + +If have enabled `CSRF protection `_ in your Django app +you will find that it prevents your API clients from POSTing to the ``graphql`` endpoint. You can either +update your API client to pass the CSRF token with each request (the Django docs have a guide on how to do that: https://docs.djangoproject.com/en/3.0/ref/csrf/#ajax) or you can exempt your Graphql endpoint from CSRF protection by wrapping the ``GraphQLView`` with the ``csrf_exempt`` +decorator: + +.. code:: python + + # urls.py + + from django.urls import path + from django.views.decorators.csrf import csrf_exempt + + from graphene_django.views import GraphQLView + + urlpatterns = [ + # ... + path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True))), + ] From 96c38b4349f5c9680531c301e3f2baf2d05b7fc9 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sat, 11 Jan 2020 14:49:44 +0100 Subject: [PATCH 035/116] Update Django model form tests (#839) * Clean up code and raise an exception if the model type is not found * Update tests * Fix tests --- graphene_django/forms/mutation.py | 26 +------ graphene_django/forms/tests/test_mutation.py | 74 ++++++++++++++++++-- 2 files changed, 70 insertions(+), 30 deletions(-) diff --git a/graphene_django/forms/mutation.py b/graphene_django/forms/mutation.py index f5921e8..1eeeb97 100644 --- a/graphene_django/forms/mutation.py +++ b/graphene_django/forms/mutation.py @@ -66,28 +66,6 @@ class BaseDjangoFormMutation(ClientIDMutation): return kwargs -# class DjangoFormInputObjectTypeOptions(InputObjectTypeOptions): -# form_class = None - - -# class DjangoFormInputObjectType(InputObjectType): -# class Meta: -# abstract = True - -# @classmethod -# def __init_subclass_with_meta__(cls, form_class=None, -# only_fields=(), exclude_fields=(), _meta=None, **options): -# if not _meta: -# _meta = DjangoFormInputObjectTypeOptions(cls) -# assert isinstance(form_class, forms.Form), ( -# 'form_class must be an instance of django.forms.Form' -# ) -# _meta.form_class = form_class -# form = form_class() -# fields = fields_for_form(form, only_fields, exclude_fields) -# super(DjangoFormInputObjectType, cls).__init_subclass_with_meta__(_meta=_meta, fields=fields, **options) - - class DjangoFormMutationOptions(MutationOptions): form_class = None @@ -163,7 +141,9 @@ class DjangoModelFormMutation(BaseDjangoFormMutation): registry = get_global_registry() model_type = registry.get_type_for_model(model) - return_field_name = return_field_name + if not model_type: + raise Exception("No type registered for model: {}".format(model.__name__)) + if not return_field_name: model_name = model.__name__ return_field_name = model_name[:1].lower() + model_name[1:] diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py index 2de5113..494c77c 100644 --- a/graphene_django/forms/tests/test_mutation.py +++ b/graphene_django/forms/tests/test_mutation.py @@ -2,6 +2,8 @@ from django import forms from django.test import TestCase from py.test import raises +from graphene import ObjectType, Schema, String, Field +from graphene_django import DjangoObjectType from graphene_django.tests.models import Film, FilmDetails, Pet from ...settings import graphene_settings @@ -18,6 +20,24 @@ class PetForm(forms.ModelForm): fields = "__all__" +class PetType(DjangoObjectType): + class Meta: + model = Pet + fields = "__all__" + + +class FilmType(DjangoObjectType): + class Meta: + model = Film + fields = "__all__" + + +class FilmDetailsType(DjangoObjectType): + class Meta: + model = FilmDetails + fields = "__all__" + + def test_needs_form_class(): with raises(Exception) as exc: @@ -59,6 +79,10 @@ def test_mutation_error_camelcased(): graphene_settings.CAMELCASE_ERRORS = False +class MockQuery(ObjectType): + a = String() + + class ModelFormMutationTests(TestCase): def test_default_meta_fields(self): class PetMutation(DjangoModelFormMutation): @@ -113,34 +137,70 @@ class ModelFormMutationTests(TestCase): self.assertEqual(PetMutation._meta.return_field_name, "animal") self.assertIn("animal", PetMutation._meta.fields) - def test_model_form_mutation_mutate(self): + def test_model_form_mutation_mutate_existing(self): class PetMutation(DjangoModelFormMutation): + pet = Field(PetType) + class Meta: form_class = PetForm + class Mutation(ObjectType): + pet_mutation = PetMutation.Field() + + schema = Schema(query=MockQuery, mutation=Mutation) + pet = Pet.objects.create(name="Axel", age=10) - result = PetMutation.mutate_and_get_payload( - None, None, id=pet.pk, name="Mia", age=10 + result = schema.execute( + """ mutation PetMutation($pk: ID!) { + petMutation(input: { id: $pk, name: "Mia", age: 10 }) { + pet { + name + age + } + } + } + """, + variables={"pk": pet.pk}, ) + self.assertIs(result.errors, None) + self.assertEqual(result.data["petMutation"]["pet"], {"name": "Mia", "age": 10}) + self.assertEqual(Pet.objects.count(), 1) pet.refresh_from_db() self.assertEqual(pet.name, "Mia") - self.assertEqual(result.errors, []) - def test_model_form_mutation_updates_existing_(self): + def test_model_form_mutation_creates_new(self): class PetMutation(DjangoModelFormMutation): + pet = Field(PetType) + class Meta: form_class = PetForm - result = PetMutation.mutate_and_get_payload(None, None, name="Mia", age=10) + class Mutation(ObjectType): + pet_mutation = PetMutation.Field() + + schema = Schema(query=MockQuery, mutation=Mutation) + + result = schema.execute( + """ mutation PetMutation { + petMutation(input: { name: "Mia", age: 10 }) { + pet { + name + age + } + } + } + """ + ) + self.assertIs(result.errors, None) + self.assertEqual(result.data["petMutation"]["pet"], {"name": "Mia", "age": 10}) self.assertEqual(Pet.objects.count(), 1) pet = Pet.objects.get() self.assertEqual(pet.name, "Mia") self.assertEqual(pet.age, 10) - self.assertEqual(result.errors, []) def test_model_form_mutation_mutate_invalid_form(self): class PetMutation(DjangoModelFormMutation): From 08f67797d8080765cd5069861bb10054f5f48289 Mon Sep 17 00:00:00 2001 From: luto Date: Sat, 11 Jan 2020 14:52:41 +0100 Subject: [PATCH 036/116] resolve django translation deprecation warnings (#847) https://docs.djangoproject.com/en/3.0/releases/3.0/#id3 --- graphene_django/forms/forms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/forms/forms.py b/graphene_django/forms/forms.py index 14e68c8..4b81859 100644 --- a/graphene_django/forms/forms.py +++ b/graphene_django/forms/forms.py @@ -2,7 +2,7 @@ import binascii from django.core.exceptions import ValidationError from django.forms import CharField, Field, MultipleChoiceField -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from graphql_relay import from_global_id From 62ecbae61449c080d0651895840dea1ed079cf0a Mon Sep 17 00:00:00 2001 From: luto Date: Mon, 20 Jan 2020 22:05:20 +0100 Subject: [PATCH 037/116] resolve django encoding deprecation warnings (#853) https://docs.djangoproject.com/en/3.0/ref/utils/#django.utils.encoding.force_text --- graphene_django/debug/sql/tracking.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/graphene_django/debug/sql/tracking.py b/graphene_django/debug/sql/tracking.py index 8391eac..a7c9d8d 100644 --- a/graphene_django/debug/sql/tracking.py +++ b/graphene_django/debug/sql/tracking.py @@ -6,7 +6,7 @@ from threading import local from time import time import six -from django.utils.encoding import force_text +from django.utils.encoding import force_str from .types import DjangoDebugSQL @@ -78,7 +78,7 @@ class NormalCursorWrapper(object): def _quote_expr(self, element): if isinstance(element, six.string_types): - return "'%s'" % force_text(element).replace("'", "''") + return "'%s'" % force_str(element).replace("'", "''") else: return repr(element) @@ -91,7 +91,7 @@ class NormalCursorWrapper(object): def _decode(self, param): try: - return force_text(param, strings_only=True) + return force_str(param, strings_only=True) except UnicodeDecodeError: return "(encoded string)" From 8ec456285bb84fad6e1ee6644543140335f613af Mon Sep 17 00:00:00 2001 From: Ilya Zhelyabuzhsky Date: Wed, 29 Jan 2020 15:06:38 +0500 Subject: [PATCH 038/116] Fix force_str deprecation warning (#858) --- graphene_django/converter.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graphene_django/converter.py b/graphene_django/converter.py index b59c906..8b93d17 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -1,6 +1,6 @@ from collections import OrderedDict from django.db import models -from django.utils.encoding import force_text +from django.utils.encoding import force_str from graphene import ( ID, @@ -30,7 +30,7 @@ singledispatch = import_single_dispatch() def convert_choice_name(name): - name = to_const(force_text(name)) + name = to_const(force_str(name)) try: assert_valid_name(name) except AssertionError: From 5c3199883f72279b5f453cd27378521d4589d72e Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Fri, 31 Jan 2020 14:20:18 +0000 Subject: [PATCH 039/116] Fix dependencies for examples (#861) --- examples/cookbook-plain/requirements.txt | 8 ++++---- examples/cookbook/requirements.txt | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/examples/cookbook-plain/requirements.txt b/examples/cookbook-plain/requirements.txt index beed53b..abbe96b 100644 --- a/examples/cookbook-plain/requirements.txt +++ b/examples/cookbook-plain/requirements.txt @@ -1,4 +1,4 @@ -graphene -graphene-django -graphql-core>=2.1rc1 -django==2.2.8 +graphene>=2.1,<3 +graphene-django>=2.1,<3 +graphql-core>=2.1,<3 +django==3.0 diff --git a/examples/cookbook/requirements.txt b/examples/cookbook/requirements.txt index b1baa57..c062358 100644 --- a/examples/cookbook/requirements.txt +++ b/examples/cookbook/requirements.txt @@ -1,5 +1,5 @@ -graphene -graphene-django -graphql-core>=2.1rc1 +graphene>=2.1,<3 +graphene-django>=2.1,<3 +graphql-core>=2.1,<3 django==3.0 django-filter>=2 From 1310509fa150088660783db80b2fc1eafe3ffc44 Mon Sep 17 00:00:00 2001 From: Jason Kraus Date: Wed, 5 Feb 2020 14:16:51 -0800 Subject: [PATCH 040/116] feature(stalebot): bug, documentation, help wanted, and enhancement added to exempt labels (#869) --- .github/stale.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/stale.yml b/.github/stale.yml index c9418f6..dab9fb3 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -6,6 +6,10 @@ daysUntilClose: 14 exemptLabels: - pinned - security + - 🐛bug + - 📖 documentation + - help wanted + - ✨enhancement # Label to use when marking an issue as stale staleLabel: wontfix # Comment to post when marking an issue as stale. Set to `false` to disable From 280b38f804f4619066062e28bcbaba9914e54ab2 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Fri, 7 Feb 2020 09:55:38 +0000 Subject: [PATCH 041/116] Only warn if a field doesn't exist on the Django model (#862) * Only warn if a field doesn't exist on the Django model Also don't warn if the field name matches a custom field. * Expand warning messages --- graphene_django/tests/test_types.py | 18 ++++++--- graphene_django/types.py | 59 ++++++++++++++++++++--------- 2 files changed, 54 insertions(+), 23 deletions(-) diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index cb31a9c..a25383f 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -320,26 +320,34 @@ def test_django_objecttype_fields_exclude_type_checking(): @with_local_registry def test_django_objecttype_fields_exclude_exist_on_model(): - with pytest.raises(Exception, match=r"Field .* doesn't exist"): + with pytest.warns(UserWarning, match=r"Field name .* doesn't exist"): class Reporter(DjangoObjectType): class Meta: model = ReporterModel fields = ["first_name", "foo", "email"] - with pytest.raises(Exception, match=r"Field .* doesn't exist"): + with pytest.warns( + UserWarning, + match=r"Field name .* matches an attribute on Django model .* but it's not a model field", + ) as record: class Reporter2(DjangoObjectType): class Meta: model = ReporterModel - exclude = ["first_name", "foo", "email"] + fields = ["first_name", "some_method", "email"] - with pytest.raises(Exception, match=r".* exists on model .* but it's not a field"): + # Don't warn if selecting a custom field + with pytest.warns(None) as record: class Reporter3(DjangoObjectType): + custom_field = String() + class Meta: model = ReporterModel - fields = ["first_name", "some_method", "email"] + fields = ["first_name", "custom_field", "email"] + + assert len(record) == 0 class TestDjangoObjectType: diff --git a/graphene_django/types.py b/graphene_django/types.py index 4824c45..129dbe1 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -33,24 +33,6 @@ def construct_fields( ): _model_fields = get_model_fields(model) - # Validate the given fields against the model's fields. - model_field_names = set(field[0] for field in _model_fields) - for fields_list in (only_fields, exclude_fields): - if not fields_list: - continue - for name in fields_list: - if name in model_field_names: - continue - - if hasattr(model, name): - raise Exception( - '"{}" exists on model {} but it\'s not a field.'.format(name, model) - ) - else: - raise Exception( - 'Field "{}" doesn\'t exist on model {}.'.format(name, model) - ) - fields = OrderedDict() for name, field in _model_fields: is_not_in_only = only_fields and name not in only_fields @@ -80,6 +62,44 @@ def construct_fields( return fields +def validate_fields(type_, model, fields, only_fields, exclude_fields): + # Validate the given fields against the model's fields and custom fields + all_field_names = set(fields.keys()) + for fields_list in (only_fields, exclude_fields): + if not fields_list: + continue + for name in fields_list: + if name in all_field_names: + continue + + if hasattr(model, name): + warnings.warn( + ( + 'Field name "{field_name}" matches an attribute on Django model "{app_label}.{object_name}" ' + "but it's not a model field so Graphene cannot determine what type it should be. " + 'Either define the type of the field on DjangoObjectType "{type_}" or remove it from the "fields" list.' + ).format( + field_name=name, + app_label=model._meta.app_label, + object_name=model._meta.object_name, + type_=type_, + ) + ) + + else: + warnings.warn( + ( + 'Field name "{field_name}" doesn\'t exist on Django model "{app_label}.{object_name}". ' + 'Consider removing the field from the "fields" list of DjangoObjectType "{type_}" because it has no effect.' + ).format( + field_name=name, + app_label=model._meta.app_label, + object_name=model._meta.object_name, + type_=type_, + ) + ) + + class DjangoObjectTypeOptions(ObjectTypeOptions): model = None # type: Model registry = None # type: Registry @@ -211,6 +231,9 @@ class DjangoObjectType(ObjectType): _meta=_meta, interfaces=interfaces, **options ) + # Validate fields + validate_fields(cls, model, _meta.fields, fields, exclude) + if not skip_registry: registry.register(cls) From f3f06086065831704093543a7728cc7852cfcf59 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Fri, 7 Feb 2020 09:59:05 +0000 Subject: [PATCH 042/116] v2.8.1 --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 1ddc2cb..f8a942d 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,6 +1,6 @@ from .types import DjangoObjectType from .fields import DjangoConnectionField -__version__ = "2.8.0" +__version__ = "2.8.1" __all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] From 6b8c5bdefc20dca773f8321774a94e627b5abca5 Mon Sep 17 00:00:00 2001 From: Ben Howes Date: Fri, 7 Feb 2020 10:16:11 +0000 Subject: [PATCH 043/116] Allow for easier template overrides in graphiql (#863) * don't replace * Update graphene_django/templates/graphene/graphiql.html Co-Authored-By: Jonathan Kim * Fix editor styling and initialisation Co-authored-by: Jonathan Kim --- graphene_django/static/graphene_django/graphiql.js | 2 +- graphene_django/templates/graphene/graphiql.html | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/graphene_django/static/graphene_django/graphiql.js b/graphene_django/static/graphene_django/graphiql.js index e38cd62..c939216 100644 --- a/graphene_django/static/graphene_django/graphiql.js +++ b/graphene_django/static/graphene_django/graphiql.js @@ -97,6 +97,6 @@ // Render into the body. ReactDOM.render( React.createElement(GraphiQL, options), - document.body + document.getElementById("editor") ); })(); diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index a0d0e1a..d0546bd 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -10,7 +10,7 @@ add "&raw" to the end of the URL within a browser. - - diff --git a/graphene_django/views.py b/graphene_django/views.py index 22b4864..59084e8 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -52,10 +52,27 @@ def instantiate_middleware(middlewares): class GraphQLView(View): - graphiql_version = "1.0.3" graphiql_template = "graphene/graphiql.html" + + # Polyfill for window.fetch. + whatwg_fetch_version = "3.2.0" + whatwg_fetch_sri = "sha256-l6HCB9TT2v89oWbDdo2Z3j+PSVypKNLA/nqfzSbM8mo=" + + # React and ReactDOM. react_version = "16.13.1" - subscriptions_transport_ws_version = "0.9.16" + react_sri = "sha256-yUhvEmYVhZ/GGshIQKArLvySDSh6cdmdcIx0spR3UP4=" + react_dom_sri = "sha256-vFt3l+illeNlwThbDUdoPTqF81M8WNSZZZt3HEjsbSU=" + + # The GraphiQL React app. + graphiql_version = "1.0.3" + graphiql_sri = "sha256-VR4buIDY9ZXSyCNFHFNik6uSe0MhigCzgN4u7moCOTk=" + graphiql_css_sri = "sha256-LwqxjyZgqXDYbpxQJ5zLQeNcf7WVNSJ+r8yp2rnWE/E=" + + # The websocket transport library for subscriptions. + subscriptions_transport_ws_version = "0.9.17" + subscriptions_transport_ws_sri = ( + "sha256-kCDzver8iRaIQ/SVlfrIwxaBQ/avXf9GQFJRLlErBnk=" + ) schema = None graphiql = False @@ -101,7 +118,7 @@ class GraphQLView(View): self.batch = self.batch or batch self.backend = backend if subscription_path is None: - subscription_path = graphene_settings.SUBSCRIPTION_PATH + self.subscription_path = graphene_settings.SUBSCRIPTION_PATH assert isinstance( self.schema, GraphQLSchema @@ -137,9 +154,18 @@ class GraphQLView(View): if show_graphiql: return self.render_graphiql( request, - graphiql_version=self.graphiql_version, + # Dependency parameters. + whatwg_fetch_version=self.whatwg_fetch_version, + whatwg_fetch_sri=self.whatwg_fetch_sri, react_version=self.react_version, + react_sri=self.react_sri, + react_dom_sri=self.react_dom_sri, + graphiql_version=self.graphiql_version, + graphiql_sri=self.graphiql_sri, + graphiql_css_sri=self.graphiql_css_sri, subscriptions_transport_ws_version=self.subscriptions_transport_ws_version, + subscriptions_transport_ws_sri=self.subscriptions_transport_ws_sri, + # The SUBSCRIPTION_PATH setting. subscription_path=self.subscription_path, ) From e439bf3727d44b0d49d6cd5f5f6758fe2dd47bec Mon Sep 17 00:00:00 2001 From: Mel van Londen Date: Sun, 12 Jul 2020 13:17:03 -0700 Subject: [PATCH 107/116] bump version to 2.12.0 --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 8e83945..49f480d 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,7 +1,7 @@ from .fields import DjangoConnectionField, DjangoListField from .types import DjangoObjectType -__version__ = "2.11.1" +__version__ = "2.12.0" __all__ = [ "__version__", From 63cfbbf59aaf9a20015194999e95b3fa5f82b6e7 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Mon, 13 Jul 2020 22:09:52 +0100 Subject: [PATCH 108/116] Remove operation name from the regex and default to query (#1004) --- graphene_django/static/graphene_django/graphiql.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/graphene_django/static/graphene_django/graphiql.js b/graphene_django/static/graphene_django/graphiql.js index 17836ef..45f8ad7 100644 --- a/graphene_django/static/graphene_django/graphiql.js +++ b/graphene_django/static/graphene_django/graphiql.js @@ -135,15 +135,18 @@ // Run a regex against the query to determine the operation type (query, mutation, subscription). var operationRegex = new RegExp( // Look for lines that start with an operation keyword, ignoring whitespace. - "^\\s*(query|mutation|subscription)\\s+" + - // The operation keyword should be followed by the operationName in the GraphQL parameters. - graphQLParams.operationName + + "^\\s*(query|mutation|subscription)\\s*" + + // The operation keyword should be followed by whitespace and the operationName in the GraphQL parameters (if available). + (graphQLParams.operationName ? ("\\s+" + graphQLParams.operationName) : "") + // The line should eventually encounter an opening curly brace. "[^\\{]*\\{", // Enable multiline matching. "m", ); var match = operationRegex.exec(graphQLParams.query); + if (!match) { + return "query"; + } return match[1]; } From b552dcac24364d3ef824f865ba419c74605942b2 Mon Sep 17 00:00:00 2001 From: Mel van Londen Date: Mon, 13 Jul 2020 14:12:42 -0700 Subject: [PATCH 109/116] bump version number --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 49f480d..f94b5be 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,7 +1,7 @@ from .fields import DjangoConnectionField, DjangoListField from .types import DjangoObjectType -__version__ = "2.12.0" +__version__ = "2.12.1" __all__ = [ "__version__", From 97de26bf2e5c38f998cc5e2fb3086345be8f0161 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Wed, 5 Aug 2020 20:17:53 +0100 Subject: [PATCH 110/116] Update tutorial docs (#994) --- docs/installation.rst | 8 +- docs/queries.rst | 151 +++++++++++++++++--------- docs/tutorial-plain.rst | 230 +++++++++++++--------------------------- docs/tutorial-relay.rst | 2 + 4 files changed, 177 insertions(+), 214 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index 048a994..35272b0 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -25,8 +25,8 @@ Add ``graphene_django`` to the ``INSTALLED_APPS`` in the ``settings.py`` file of INSTALLED_APPS = [ ... - 'django.contrib.staticfiles', # Required for GraphiQL - 'graphene_django' + "django.contrib.staticfiles", # Required for GraphiQL + "graphene_django" ] @@ -63,7 +63,7 @@ Finally, define the schema location for Graphene in the ``settings.py`` file of .. code:: python GRAPHENE = { - 'SCHEMA': 'django_root.schema.schema' + "SCHEMA": "django_root.schema.schema" } Where ``path.schema.schema`` is the location of the ``Schema`` object in your Django project. @@ -75,7 +75,7 @@ The most basic ``schema.py`` looks like this: import graphene class Query(graphene.ObjectType): - pass + hello = graphene.String(default_value="Hi!") schema = graphene.Schema(query=Query) diff --git a/docs/queries.rst b/docs/queries.rst index 4b3f718..02a2bf2 100644 --- a/docs/queries.rst +++ b/docs/queries.rst @@ -20,27 +20,26 @@ Full example # my_app/schema.py import graphene + from graphene_django import DjangoObjectType - from graphene_django.types import DjangoObjectType from .models import Question - class QuestionType(DjangoObjectType): class Meta: model = Question + fields = ("id", "question_text") - - class Query: + class Query(graphene.ObjectType): questions = graphene.List(QuestionType) - question = graphene.Field(QuestionType, question_id=graphene.String()) + question_by_id = graphene.Field(QuestionType, id=graphene.String()) - def resolve_questions(self, info, **kwargs): + def resolve_questions(root, info, **kwargs): # Querying a list return Question.objects.all() - def resolve_question(self, info, question_id): + def resolve_question_by_id(root, info, id): # Querying a single question - return Question.objects.get(pk=question_id) + return Question.objects.get(pk=id) Specifying which fields to include @@ -60,21 +59,27 @@ Show **only** these fields on the model: .. code:: python + from graphene_django import DjangoObjectType + from .models import Question + class QuestionType(DjangoObjectType): class Meta: model = Question - fields = ('id', 'question_text') + fields = ("id", "question_text") -You can also set the ``fields`` attribute to the special value ``'__all__'`` to indicate that all fields in the model should be used. +You can also set the ``fields`` attribute to the special value ``"__all__"`` to indicate that all fields in the model should be used. For example: .. code:: python + from graphene_django import DjangoObjectType + from .models import Question + class QuestionType(DjangoObjectType): class Meta: model = Question - fields = '__all__' + fields = "__all__" ``exclude`` @@ -84,10 +89,13 @@ Show all fields **except** those in ``exclude``: .. code:: python + from graphene_django import DjangoObjectType + from .models import Question + class QuestionType(DjangoObjectType): class Meta: model = Question - exclude = ('question_text',) + exclude = ("question_text",) Customising fields @@ -97,16 +105,19 @@ You can completely overwrite a field, or add new fields, to a ``DjangoObjectType .. code:: python + from graphene_django import DjangoObjectType + from .models import Question + class QuestionType(DjangoObjectType): class Meta: model = Question - fields = ('id', 'question_text') + fields = ("id", "question_text") extra_field = graphene.String() def resolve_extra_field(self, info): - return 'hello!' + return "hello!" Choices to Enum conversion @@ -121,12 +132,19 @@ For example the following ``Model`` and ``DjangoObjectType``: .. code:: python - class PetModel(models.Model): - kind = models.CharField(max_length=100, choices=(('cat', 'Cat'), ('dog', 'Dog'))) + from django.db import models + from graphene_django import DjangoObjectType - class Pet(DjangoObjectType): - class Meta: - model = PetModel + class PetModel(models.Model): + kind = models.CharField( + max_length=100, + choices=(("cat", "Cat"), ("dog", "Dog")) + ) + + class Pet(DjangoObjectType): + class Meta: + model = PetModel + fields = ("id", "kind",) Results in the following GraphQL schema definition: @@ -148,27 +166,35 @@ You can disable this automatic conversion by setting .. code:: python - class Pet(DjangoObjectType): - class Meta: - model = PetModel - convert_choices_to_enum = False + from graphene_django import DjangoObjectType + from .models import PetModel + + class Pet(DjangoObjectType): + class Meta: + model = PetModel + fields = ("id", "kind",) + convert_choices_to_enum = False .. code:: - type Pet { - id: ID! - kind: String! - } + type Pet { + id: ID! + kind: String! + } You can also set ``convert_choices_to_enum`` to a list of fields that should be automatically converted into enums: .. code:: python - class Pet(DjangoObjectType): - class Meta: - model = PetModel - convert_choices_to_enum = ['kind'] + from graphene_django import DjangoObjectType + from .models import PetModel + + class Pet(DjangoObjectType): + class Meta: + model = PetModel + fields = ("id", "kind",) + convert_choices_to_enum = ["kind"] **Note:** Setting ``convert_choices_to_enum = []`` is the same as setting it to ``False``. @@ -181,6 +207,8 @@ Say you have the following models: .. code:: python + from django.db import models + class Category(models.Model): foo = models.CharField(max_length=256) @@ -192,10 +220,13 @@ When ``Question`` is published as a ``DjangoObjectType`` and you want to add ``C .. code:: python + from graphene_django import DjangoObjectType + from .models import Question + class QuestionType(DjangoObjectType): class Meta: model = Question - fields = ('category',) + fields = ("category",) Then all query-able related models must be defined as DjangoObjectType subclass, or they will fail to show if you are trying to query those relation fields. You only @@ -203,9 +234,13 @@ need to create the most basic class for this to work: .. code:: python + from graphene_django import DjangoObjectType + from .models import Category + class CategoryType(DjangoObjectType): class Meta: model = Category + fields = ("foo",) .. _django-objecttype-get-queryset: @@ -220,7 +255,6 @@ Use this to control filtering on the ObjectType level instead of the Query objec from graphene_django.types import DjangoObjectType from .models import Question - class QuestionType(DjangoObjectType): class Meta: model = Question @@ -240,18 +274,22 @@ This resolve method should follow this format: .. code:: python - def resolve_foo(self, info, **kwargs): + def resolve_foo(parent, info, **kwargs): Where "foo" is the name of the field declared in the ``Query`` object. .. code:: python - class Query: + import graphene + from .models import Question + from .types import QuestionType + + class Query(graphene.ObjectType): foo = graphene.List(QuestionType) - def resolve_foo(self, info, **kwargs): - id = kwargs.get('id') - return QuestionModel.objects.get(id) + def resolve_foo(root, info): + id = kwargs.get("id") + return Question.objects.get(id) Arguments ~~~~~~~~~ @@ -260,10 +298,18 @@ Additionally, Resolvers will receive **any arguments declared in the field defin .. code:: python - class Query: - question = graphene.Field(Question, foo=graphene.String(), bar=graphene.Int()) + import graphene + from .models import Question + from .types import QuestionType - def resolve_question(self, info, foo, bar): + class Query(graphene.ObjectType): + question = graphene.Field( + QuestionType, + foo=graphene.String(), + bar=graphene.Int() + ) + + def resolve_question(root, info, foo, bar): # If `foo` or `bar` are declared in the GraphQL query they will be here, else None. return Question.objects.filter(foo=foo, bar=bar).first() @@ -278,7 +324,15 @@ of Django's ``HTTPRequest`` in your resolve methods, such as checking for authen .. code:: python - def resolve_questions(self, info, **kwargs): + import graphene + + from .models import Question + from .types import QuestionType + + class Query(graphene.ObjectType): + questions = graphene.List(QuestionType) + + def resolve_questions(root, info): # See if a user is authenticated if info.context.user.is_authenticated(): return Question.objects.all() @@ -305,15 +359,13 @@ Django models and your external API. import graphene from .models import Question - class MyQuestion(graphene.ObjectType): text = graphene.String() - - class Query: + class Query(graphene.ObjectType): question = graphene.Field(MyQuestion, question_id=graphene.String()) - def resolve_question(self, info, question_id): + def resolve_question(root, info, question_id): question = Question.objects.get(pk=question_id) return MyQuestion( text=question.question_text @@ -343,25 +395,22 @@ the core graphene pages for more information on customizing the Relay experience from graphene_django import DjangoObjectType from .models import Question - class QuestionType(DjangoObjectType): class Meta: model = Question - interfaces = (relay.Node,) - + interfaces = (relay.Node,) # make sure you add this + fields = "__all__" class QuestionConnection(relay.Connection): class Meta: node = QuestionType - class Query: questions = relay.ConnectionField(QuestionConnection) def resolve_questions(root, info, **kwargs): return Question.objects.all() - You can now execute queries like: diff --git a/docs/tutorial-plain.rst b/docs/tutorial-plain.rst index e80f9ab..45927a5 100644 --- a/docs/tutorial-plain.rst +++ b/docs/tutorial-plain.rst @@ -3,15 +3,11 @@ Basic Tutorial Graphene Django has a number of additional features that are designed to make working with Django easy. Our primary focus in this tutorial is to give a good -understanding of how to connect models from Django ORM to graphene object types. +understanding of how to connect models from Django ORM to Graphene object types. Set up the Django project ------------------------- -You can find the entire project in ``examples/cookbook-plain``. - ----- - We will set up the project, create the following: - A Django project called ``cookbook`` @@ -28,13 +24,12 @@ We will set up the project, create the following: source env/bin/activate # On Windows use `env\Scripts\activate` # Install Django and Graphene with Django support - pip install django - pip install graphene_django + pip install django graphene_django # Set up a new project with a single application - django-admin.py startproject cookbook . # Note the trailing '.' character + django-admin startproject cookbook . # Note the trailing '.' character cd cookbook - django-admin.py startapp ingredients + django-admin startapp ingredients Now sync your database for the first time: @@ -54,19 +49,18 @@ Let's get started with these models: # cookbook/ingredients/models.py from django.db import models - class Category(models.Model): name = models.CharField(max_length=100) def __str__(self): return self.name - class Ingredient(models.Model): name = models.CharField(max_length=100) notes = models.TextField() category = models.ForeignKey( - Category, related_name='ingredients', on_delete=models.CASCADE) + Category, related_name="ingredients", on_delete=models.CASCADE + ) def __str__(self): return self.name @@ -75,10 +69,12 @@ Add ingredients as INSTALLED_APPS: .. code:: python + # cookbook/settings.py + INSTALLED_APPS = [ ... # Install the ingredients app - 'cookbook.ingredients', + "cookbook.ingredients", ] @@ -102,13 +98,13 @@ following: .. code:: bash - $ python ./manage.py loaddata ingredients + python manage.py loaddata ingredients Installed 6 object(s) from 1 fixture(s) Alternatively you can use the Django admin interface to create some data yourself. You'll need to run the development server (see below), and -create a login for yourself too (``./manage.py createsuperuser``). +create a login for yourself too (``python manage.py createsuperuser``). Register models with admin panel: @@ -138,66 +134,48 @@ order to create this representation, Graphene needs to know about each This graph also has a *root type* through which all access begins. This is the ``Query`` class below. -This means, for each of our models, we are going to create a type, subclassing ``DjangoObjectType`` +To create GraphQL types for each of our Django models, we are going to subclass the ``DjangoObjectType`` class which will automatically define GraphQL fields that correspond to the fields on the Django models. After we've done that, we will list those types as fields in the ``Query`` class. -Create ``cookbook/ingredients/schema.py`` and type the following: +Create ``cookbook/schema.py`` and type the following: .. code:: python - # cookbook/ingredients/schema.py + # cookbook/schema.py import graphene - - from graphene_django.types import DjangoObjectType + from graphene_django import DjangoObjectType from cookbook.ingredients.models import Category, Ingredient - class CategoryType(DjangoObjectType): class Meta: model = Category - + fields = ("id", "name", "ingredients") class IngredientType(DjangoObjectType): class Meta: model = Ingredient + fields = ("id", "name", "notes", "category") - - class Query(object): - all_categories = graphene.List(CategoryType) + class Query(graphene.ObjectType): all_ingredients = graphene.List(IngredientType) + category_by_name = graphene.Field(CategoryType, name=graphene.String(required=True)) - def resolve_all_categories(self, info, **kwargs): - return Category.objects.all() - - def resolve_all_ingredients(self, info, **kwargs): + def resolve_all_ingredients(root, info): # We can easily optimize query count in the resolve method - return Ingredient.objects.select_related('category').all() + return Ingredient.objects.select_related("category").all() - -Note that the above ``Query`` class is a mixin, inheriting from -``object``. This is because we will now create a project-level query -class which will combine all our app-level mixins. - -Create the parent project-level ``cookbook/schema.py``: - -.. code:: python - - import graphene - - import cookbook.ingredients.schema - - - class Query(cookbook.ingredients.schema.Query, graphene.ObjectType): - # This class will inherit from multiple Queries - # as we begin to add more apps to our project - pass + def resolve_category_by_name(root, info, name): + try: + return Category.objects.get(name=name) + except Category.DoesNotExist: + return None schema = graphene.Schema(query=Query) You can think of this as being something like your top-level ``urls.py`` -file (although it currently lacks any namespacing). +file. Testing everything so far ------------------------- @@ -216,18 +194,21 @@ Add ``graphene_django`` to ``INSTALLED_APPS`` in ``cookbook/settings.py``: .. code:: python + # cookbook/settings.py + INSTALLED_APPS = [ ... - # This will also make the `graphql_schema` management command available - 'graphene_django', + "graphene_django", ] And then add the ``SCHEMA`` to the ``GRAPHENE`` config in ``cookbook/settings.py``: .. code:: python + # cookbook/settings.py + GRAPHENE = { - 'SCHEMA': 'cookbook.schema.schema' + "SCHEMA": "cookbook.schema.schema" } Alternatively, we can specify the schema to be used in the urls definition, @@ -245,14 +226,17 @@ aforementioned GraphiQL we specify that on the parameters with ``graphiql=True`` .. code:: python - from django.conf.urls import url, include + # cookbook/urls.py + from django.contrib import admin + from django.urls import path + from django.views.decorators.csrf import csrf_exempt from graphene_django.views import GraphQLView urlpatterns = [ - url(r'^admin/', admin.site.urls), - url(r'^graphql$', GraphQLView.as_view(graphiql=True)), + path("admin/", admin.site.urls), + path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True))), ] @@ -261,16 +245,19 @@ as explained above, we can do so here using: .. code:: python - from django.conf.urls import url, include + # cookbook/urls.py + from django.contrib import admin + from django.urls import path + from django.views.decorators.csrf import csrf_exempt from graphene_django.views import GraphQLView from cookbook.schema import schema urlpatterns = [ - url(r'^admin/', admin.site.urls), - url(r'^graphql$', GraphQLView.as_view(graphiql=True, schema=schema)), + path("admin/", admin.site.urls), + path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema))), ] @@ -283,10 +270,10 @@ from the command line. .. code:: bash - $ python ./manage.py runserver + python manage.py runserver Performing system checks... - Django version 1.11, using settings 'cookbook.settings' + Django version 3.0.7, using settings 'cookbook.settings' Starting development server at http://127.0.0.1:8000/ Quit the server with CONTROL-C. @@ -329,24 +316,25 @@ If you are using the provided fixtures, you will see the following response: } } -You can experiment with ``allCategories`` too. -Something to have in mind is the `auto camelcasing `__ that is happening. +Congratulations, you have created a working GraphQL server 🥳! + +Note: Graphene `automatically camelcases `__ all field names for better compatibility with JavaScript clients. Getting relations ----------------- -Right now, with this simple setup in place, we can query for relations too. This is where graphql becomes really powerful! +Using the current schema we can query for relations too. This is where GraphQL becomes really powerful! -For example, we may want to list all categories and in each category, all ingredients that are in that category. +For example, we may want to get a specific categories and list all ingredients that are in that category. We can do that with the following query: .. code:: query { - allCategories { + categoryByName(name: "Dairy") { id name ingredients { @@ -356,43 +344,26 @@ We can do that with the following query: } } - This will give you (in case you are using the fixtures) the following result: .. code:: { "data": { - "allCategories": [ - { - "id": "1", - "name": "Dairy", - "ingredients": [ - { - "id": "1", - "name": "Eggs" - }, - { - "id": "2", - "name": "Milk" - } - ] - }, - { - "id": "2", - "name": "Meat", - "ingredients": [ - { - "id": "3", - "name": "Beef" - }, - { - "id": "4", - "name": "Chicken" - } - ] - } - ] + "categoryByName": { + "id": "1", + "name": "Dairy", + "ingredients": [ + { + "id": "1", + "name": "Eggs" + }, + { + "id": "2", + "name": "Milk" + } + ] + } } } @@ -411,71 +382,12 @@ We can also list all ingredients and get information for the category they are i } } -Getting single objects ----------------------- - -So far, we have been able to fetch list of objects and follow relation. But what about single objects? - -We can update our schema to support that, by adding new query for ``ingredient`` and ``category`` and adding arguments, so we can query for specific objects. -Add the **Highlighted** lines to ``cookbook/ingredients/schema.py`` - -.. literalinclude:: schema.py - :emphasize-lines: 19-21,25-27,36-58 - -Now, with the code in place, we can query for single objects. - -For example, lets query ``category``: - - -.. code:: - - query { - category(id: 1) { - name - } - anotherCategory: category(name: "Dairy") { - ingredients { - id - name - } - } - } - -This will give us the following results: - -.. code:: - - { - "data": { - "category": { - "name": "Dairy" - }, - "anotherCategory": { - "ingredients": [ - { - "id": "1", - "name": "Eggs" - }, - { - "id": "2", - "name": "Milk" - } - ] - } - } - } - -As an exercise, you can try making some queries to ``ingredient``. - -Something to keep in mind - since we are using one field several times in our query, we need `aliases `__ - - Summary ------- -As you can see, GraphQL is very powerful but there are a lot of repetitions in our example. We can do a lot of improvements by adding layers of abstraction on top of ``graphene-django``. +As you can see, GraphQL is very powerful and integrating Django models allows you to get started with a working server quickly. -If you want to put things like ``django-filter`` and automatic pagination in action, you should continue with the **relay tutorial.** +If you want to put things like ``django-filter`` and automatic pagination in action, you should continue with the :ref:`Relay tutorial`. -A good idea is to check the `graphene `__ -documentation but it is not essential to understand and use Graphene-Django in your project. \ No newline at end of file +A good idea is to check the `Graphene `__ +documentation so that you are familiar with it as well. diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst index e900ea1..94f1aa7 100644 --- a/docs/tutorial-relay.rst +++ b/docs/tutorial-relay.rst @@ -1,3 +1,5 @@ +.. _Relay tutorial: + Relay tutorial ======================================== From 2308965658f34a059f59b8c0c4fa786a6adeea7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolai=20R=C3=B8ed=20Kristiansen?= Date: Wed, 5 Aug 2020 21:24:16 +0200 Subject: [PATCH 111/116] Extract query function from GraphQLTestCase making it possible to use in a pytest fixture (#1015) --- docs/testing.rst | 42 +++++++++++-- graphene_django/tests/test_utils.py | 27 ++++++++ graphene_django/utils/testing.py | 97 ++++++++++++++++++++--------- 3 files changed, 132 insertions(+), 34 deletions(-) diff --git a/docs/testing.rst b/docs/testing.rst index 473a9ba..23acef2 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -1,6 +1,9 @@ Testing API calls with django ============================= +Using unittest +-------------- + If you want to unittest your API calls derive your test case from the class `GraphQLTestCase`. Your endpoint is set through the `GRAPHQL_URL` attribute on `GraphQLTestCase`. The default endpoint is `GRAPHQL_URL = "/graphql/"`. @@ -12,12 +15,8 @@ Usage: import json from graphene_django.utils.testing import GraphQLTestCase - from my_project.config.schema import schema class MyFancyTestCase(GraphQLTestCase): - # Here you need to inject your test case's schema - GRAPHQL_SCHEMA = schema - def test_some_query(self): response = self.query( ''' @@ -82,3 +81,38 @@ Usage: # Add some more asserts if you like ... + +Using pytest +------------ + +To use pytest define a simple fixture using the query helper below + +.. code:: python + + # Create a fixture using the graphql_query helper and `client` fixture from `pytest-django`. + import pytest + from graphene_django.utils.testing import graphql_query + + @pytest.fixture + def client_query(client) + def func(*args, **kwargs): + return graphql_query(*args, **kwargs, client=client) + + return func + + # Test you query using the client_query fixture + def test_some_query(client_query): + response = graphql_query( + ''' + query { + myModel { + id + name + } + } + ''', + op_name='myModel' + ) + + content = json.loads(response.content) + assert 'errors' not in content \ No newline at end of file diff --git a/graphene_django/tests/test_utils.py b/graphene_django/tests/test_utils.py index c0d376b..f5a8b05 100644 --- a/graphene_django/tests/test_utils.py +++ b/graphene_django/tests/test_utils.py @@ -6,6 +6,7 @@ from mock import patch from ..utils import camelize, get_model_fields, GraphQLTestCase from .models import Film, Reporter +from ..utils.testing import graphql_query def test_get_model_fields_no_duplication(): @@ -58,3 +59,29 @@ def test_graphql_test_case_op_name(post_mock): "operationName", "QueryName", ) in body.items(), "Field 'operationName' is not present in the final request." + + +@pytest.mark.django_db +@patch("graphene_django.utils.testing.Client.post") +def test_graphql_query_case_op_name(post_mock): + graphql_query("query { }", op_name="QueryName") + body = json.loads(post_mock.call_args.args[1]) + # `operationName` field from https://graphql.org/learn/serving-over-http/#post-request + assert ( + "operationName", + "QueryName", + ) in body.items(), "Field 'operationName' is not present in the final request." + + +@pytest.fixture +def client_query(client): + def func(*args, **kwargs): + return graphql_query(*args, client=client, **kwargs) + + return func + + +def test_pytest_fixture_usage(client_query): + response = graphql_query("query { test }") + content = json.loads(response.content) + assert content == {"data": {"test": "Hello World"}} diff --git a/graphene_django/utils/testing.py b/graphene_django/utils/testing.py index 0f68a51..6b2d3e8 100644 --- a/graphene_django/utils/testing.py +++ b/graphene_django/utils/testing.py @@ -2,6 +2,63 @@ import json from django.test import TestCase, Client +DEFAULT_GRAPHQL_URL = "/graphql/" + + +def graphql_query( + query, + op_name=None, + input_data=None, + variables=None, + headers=None, + client=None, + graphql_url=None, +): + """ + Args: + query (string) - GraphQL query to run + op_name (string) - If the query is a mutation or named query, you must + supply the op_name. For annon queries ("{ ... }"), + should be None (default). + input_data (dict) - If provided, the $input variable in GraphQL will be set + to this value. If both ``input_data`` and ``variables``, + are provided, the ``input`` field in the ``variables`` + dict will be overwritten with this value. + variables (dict) - If provided, the "variables" field in GraphQL will be + set to this value. + headers (dict) - If provided, the headers in POST request to GRAPHQL_URL + will be set to this value. + client (django.test.Client) - Test client. Defaults to django.test.Client. + graphql_url (string) - URL to graphql endpoint. Defaults to "/graphql". + + Returns: + Response object from client + """ + if client is None: + client = Client() + if not graphql_url: + graphql_url = DEFAULT_GRAPHQL_URL + + body = {"query": query} + if op_name: + body["operationName"] = op_name + if variables: + body["variables"] = variables + if input_data: + if variables in body: + body["variables"]["input"] = input_data + else: + body["variables"] = {"input": input_data} + if headers: + resp = client.post( + graphql_url, json.dumps(body), content_type="application/json", **headers + ) + else: + resp = client.post( + graphql_url, json.dumps(body), content_type="application/json" + ) + return resp + class GraphQLTestCase(TestCase): """ @@ -9,19 +66,12 @@ class GraphQLTestCase(TestCase): """ # URL to graphql endpoint - GRAPHQL_URL = "/graphql/" - # Here you need to set your graphql schema for the tests - GRAPHQL_SCHEMA = None + GRAPHQL_URL = DEFAULT_GRAPHQL_URL @classmethod def setUpClass(cls): super(GraphQLTestCase, cls).setUpClass() - if not cls.GRAPHQL_SCHEMA: - raise AttributeError( - "Variable GRAPHQL_SCHEMA not defined in GraphQLTestCase." - ) - cls._client = Client() def query(self, query, op_name=None, input_data=None, variables=None, headers=None): @@ -43,28 +93,15 @@ class GraphQLTestCase(TestCase): Returns: Response object from client """ - body = {"query": query} - if op_name: - body["operationName"] = op_name - if variables: - body["variables"] = variables - if input_data: - if variables in body: - body["variables"]["input"] = input_data - else: - body["variables"] = {"input": input_data} - if headers: - resp = self._client.post( - self.GRAPHQL_URL, - json.dumps(body), - content_type="application/json", - **headers - ) - else: - resp = self._client.post( - self.GRAPHQL_URL, json.dumps(body), content_type="application/json" - ) - return resp + return graphql_query( + query, + op_name=op_name, + input_data=input_data, + variables=variables, + headers=headers, + client=self._client, + graphql_url=self.GRAPHQL_URL, + ) def assertResponseNoErrors(self, resp): """ From 55769e814f3fc3da6c6d39696d6d1460fd8c9c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Kowalski?= Date: Fri, 7 Aug 2020 11:13:26 +0200 Subject: [PATCH 112/116] Add headers support to GraphiQL (#1016) Co-authored-by: Jonathan Kim --- docs/settings.rst | 21 +++++++++++++++++++ graphene_django/settings.py | 4 ++++ .../static/graphene_django/graphiql.js | 19 ++++++++++------- .../templates/graphene/graphiql.html | 1 + graphene_django/views.py | 2 ++ 5 files changed, 39 insertions(+), 8 deletions(-) diff --git a/docs/settings.rst b/docs/settings.rst index c2f8600..1e82e70 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -186,3 +186,24 @@ Default: ``None`` GRAPHENE = { 'SUBSCRIPTION_PATH': "/ws/graphql" } + + +``GRAPHIQL_HEADER_EDITOR_ENABLED`` +--------------------- + +GraphiQL starting from version 1.0.0 allows setting custom headers in similar fashion to query variables. + +Set to ``False`` if you want to disable GraphiQL headers editor tab for some reason. + +This setting is passed to ``headerEditorEnabled`` GraphiQL options, for details refer to GraphiQLDocs_. + +.. _GraphiQLDocs: https://github.com/graphql/graphiql/tree/main/packages/graphiql#options + + +Default: ``True`` + +.. code:: python + + GRAPHENE = { + 'GRAPHIQL_HEADER_EDITOR_ENABLED': True, + } diff --git a/graphene_django/settings.py b/graphene_django/settings.py index 52cca89..71b791c 100644 --- a/graphene_django/settings.py +++ b/graphene_django/settings.py @@ -41,6 +41,10 @@ DEFAULTS = { "DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME": None, # Use a separate path for handling subscriptions. "SUBSCRIPTION_PATH": None, + # By default GraphiQL headers editor tab is enabled, set to False to hide it + # This sets headerEditorEnabled GraphiQL option, for details go to + # https://github.com/graphql/graphiql/tree/main/packages/graphiql#options + "GRAPHIQL_HEADER_EDITOR_ENABLED": True, } if settings.DEBUG: diff --git a/graphene_django/static/graphene_django/graphiql.js b/graphene_django/static/graphene_django/graphiql.js index 45f8ad7..8c3b5ce 100644 --- a/graphene_django/static/graphene_django/graphiql.js +++ b/graphene_django/static/graphene_django/graphiql.js @@ -61,13 +61,15 @@ var fetchURL = locationQuery(otherParams); // Defines a GraphQL fetcher using the fetch API. - function httpClient(graphQLParams) { - var headers = { - Accept: "application/json", - "Content-Type": "application/json", - }; + function httpClient(graphQLParams, opts) { + if (typeof opts === 'undefined') { + opts = {}; + } + var headers = opts.headers || {}; + headers['Accept'] = headers['Accept'] || 'application/json'; + headers['Content-Type'] = headers['Content-Type'] || 'application/json'; if (csrftoken) { - headers["X-CSRFToken"] = csrftoken; + headers['X-CSRFToken'] = csrftoken } return fetch(fetchURL, { method: "post", @@ -108,7 +110,7 @@ var activeSubscription = null; // Define a GraphQL fetcher that can intelligently route queries based on the operation type. - function graphQLFetcher(graphQLParams) { + function graphQLFetcher(graphQLParams, opts) { var operationType = getOperationType(graphQLParams); // If we're about to execute a new operation, and we have an active subscription, @@ -126,7 +128,7 @@ }, }; } else { - return httpClient(graphQLParams); + return httpClient(graphQLParams, opts); } } @@ -173,6 +175,7 @@ onEditQuery: onEditQuery, onEditVariables: onEditVariables, onEditOperationName: onEditOperationName, + headerEditorEnabled: GRAPHENE_SETTINGS.graphiqlHeaderEditorEnabled, query: parameters.query, }; if (parameters.variables) { diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index abc4b52..cec4893 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -45,6 +45,7 @@ add "&raw" to the end of the URL within a browser. {% if subscription_path %} subscriptionPath: "{{subscription_path}}", {% endif %} + graphiqlHeaderEditorEnabled: {{ graphiql_header_editor_enabled|yesno:"true,false" }}, }; diff --git a/graphene_django/views.py b/graphene_django/views.py index 59084e8..5ee0297 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -167,6 +167,8 @@ class GraphQLView(View): subscriptions_transport_ws_sri=self.subscriptions_transport_ws_sri, # The SUBSCRIPTION_PATH setting. subscription_path=self.subscription_path, + # GraphiQL headers tab, + graphiql_header_editor_enabled=graphene_settings.GRAPHIQL_HEADER_EDITOR_ENABLED, ) if self.batch: From 11dbde3beaa9882277082ec108b993c36be62f4e Mon Sep 17 00:00:00 2001 From: Thomas Leonard <64223923+tcleonard@users.noreply.github.com> Date: Fri, 7 Aug 2020 10:15:35 +0100 Subject: [PATCH 113/116] Fix Connection/Edge naming and add unit test (#1012) Co-authored-by: Thomas Leonard --- graphene_django/tests/test_types.py | 26 ++++++++++++++++++++++++++ graphene_django/types.py | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 4d14749..fb95820 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -9,6 +9,7 @@ from graphene import Connection, Field, Interface, ObjectType, Schema, String from graphene.relay import Node from .. import registry +from ..filter import DjangoFilterConnectionField from ..types import DjangoObjectType, DjangoObjectTypeOptions from .models import Article as ArticleModel from .models import Reporter as ReporterModel @@ -580,3 +581,28 @@ class TestDjangoObjectType: } """ ) + + +@with_local_registry +def test_django_objecttype_name_connection_propagation(): + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + name = "CustomReporterName" + filter_fields = ["email"] + interfaces = (Node,) + + class Query(ObjectType): + reporter = Node.Field(Reporter) + reporters = DjangoFilterConnectionField(Reporter) + + assert Reporter._meta.name == "CustomReporterName" + schema = str(Schema(query=Query)) + + assert "type CustomReporterName implements Node {" in schema + assert "type CustomReporterNameConnection {" in schema + assert "type CustomReporterNameEdge {" in schema + + assert "type Reporter implements Node {" not in schema + assert "type ReporterConnection {" not in schema + assert "type ReporterEdge {" not in schema diff --git a/graphene_django/types.py b/graphene_django/types.py index b31fd0f..e38ae1f 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -239,7 +239,7 @@ class DjangoObjectType(ObjectType): connection_class = Connection connection = connection_class.create_type( - "{}Connection".format(cls.__name__), node=cls + "{}Connection".format(options.get("name") or cls.__name__), node=cls ) if connection is not None: From 67a0492c124587a98435621565c00b3f9e9053f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolai=20R=C3=B8ed=20Kristiansen?= Date: Fri, 7 Aug 2020 11:22:15 +0200 Subject: [PATCH 114/116] Add converter for django 3.1 JSONField (#1017) --- .github/workflows/tests.yml | 2 +- graphene_django/compat.py | 10 ++++++++-- graphene_django/converter.py | 5 +++-- graphene_django/tests/test_converter.py | 16 ++++++++++++++-- tox.ini | 4 +++- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 37453f0..b9e57b5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: max-parallel: 4 matrix: - django: ["1.11", "2.2", "3.0"] + django: ["1.11", "2.2", "3.0", "3.1"] python-version: ["3.6", "3.7", "3.8"] include: - django: "1.11" diff --git a/graphene_django/compat.py b/graphene_django/compat.py index 59fab30..6e5e769 100644 --- a/graphene_django/compat.py +++ b/graphene_django/compat.py @@ -8,8 +8,14 @@ try: from django.contrib.postgres.fields import ( ArrayField, HStoreField, - JSONField, + JSONField as PGJSONField, RangeField, ) except ImportError: - ArrayField, HStoreField, JSONField, RangeField = (MissingType,) * 4 + ArrayField, HStoreField, PGJSONField, RangeField = (MissingType,) * 4 + +try: + # JSONField is only available from Django 3.1 + from django.contrib.fields import JSONField +except ImportError: + JSONField = MissingType diff --git a/graphene_django/converter.py b/graphene_django/converter.py index ca524ff..0de6964 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -24,7 +24,7 @@ from graphene.utils.str_converters import to_camel_case from graphql import assert_valid_name from .settings import graphene_settings -from .compat import ArrayField, HStoreField, JSONField, RangeField +from .compat import ArrayField, HStoreField, JSONField, PGJSONField, RangeField from .fields import DjangoListField, DjangoConnectionField from .utils import import_single_dispatch from .utils.str_converters import to_const @@ -267,8 +267,9 @@ def convert_postgres_array_to_list(field, registry=None): @convert_django_field.register(HStoreField) +@convert_django_field.register(PGJSONField) @convert_django_field.register(JSONField) -def convert_postgres_field_to_string(field, registry=None): +def convert_pg_and_json_field_to_string(field, registry=None): return JSONString(description=field.help_text, required=not field.null) diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index f6e3606..7d8e669 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -11,7 +11,14 @@ from graphene.relay import ConnectionField, Node from graphene.types.datetime import Date, DateTime, Time from graphene.types.json import JSONString -from ..compat import ArrayField, HStoreField, JSONField, MissingType, RangeField +from ..compat import ( + ArrayField, + HStoreField, + JSONField, + PGJSONField, + MissingType, + RangeField, +) from ..converter import ( convert_django_field, convert_django_field_with_choices, @@ -348,8 +355,13 @@ def test_should_postgres_hstore_convert_string(): assert_conversion(HStoreField, JSONString) -@pytest.mark.skipif(JSONField is MissingType, reason="JSONField should exist") +@pytest.mark.skipif(PGJSONField is MissingType, reason="PGJSONField should exist") def test_should_postgres_json_convert_string(): + assert_conversion(PGJSONField, JSONString) + + +@pytest.mark.skipif(JSONField is MissingType, reason="JSONField should exist") +def test_should_json_convert_string(): assert_conversion(JSONField, JSONString) diff --git a/tox.ini b/tox.ini index 6744c5b..9086a55 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = py{27,35,36,37,38}-django{111,20,21,22,master}, - py{36,37,38}-django30, + py{36,37,38}-django{30,31}, black,flake8 [gh-actions] @@ -18,6 +18,7 @@ DJANGO = 2.1: django21 2.2: django22 3.0: django30 + 3.1: django31 master: djangomaster [testenv] @@ -33,6 +34,7 @@ deps = django21: Django>=2.1,<2.2 django22: Django>=2.2,<3.0 django30: Django>=3.0a1,<3.1 + django31: Django>=3.1,<3.2 djangomaster: https://github.com/django/django/archive/master.zip commands = {posargs:py.test --cov=graphene_django graphene_django examples} From bd553be10e6200c6558cc7dd91d7bc3743325d6e Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Wed, 12 Aug 2020 07:03:23 +0100 Subject: [PATCH 115/116] Fix JSONField import (#1021) --- graphene_django/compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/compat.py b/graphene_django/compat.py index 6e5e769..8a2b933 100644 --- a/graphene_django/compat.py +++ b/graphene_django/compat.py @@ -16,6 +16,6 @@ except ImportError: try: # JSONField is only available from Django 3.1 - from django.contrib.fields import JSONField + from django.db.models import JSONField except ImportError: JSONField = MissingType From 5b1451132d80869ac46eb42faac2836bda6c658a Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Wed, 12 Aug 2020 07:10:01 +0100 Subject: [PATCH 116/116] v2.13.0 --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index f94b5be..03ccbeb 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,7 +1,7 @@ from .fields import DjangoConnectionField, DjangoListField from .types import DjangoObjectType -__version__ = "2.12.1" +__version__ = "2.13.0" __all__ = [ "__version__",