From 4bbc0824a623c874dd2726b7aed5bdeb563dda3b Mon Sep 17 00:00:00 2001 From: Tyler Kennedy Date: Tue, 17 Sep 2019 12:13:47 -0400 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] =?UTF-8?q?Pin=20higher=20version=20of=20graphene=20fo?= =?UTF-8?q?r=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 5/8] 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 6/8] 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 7/8] 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 8/8] 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"]