diff --git a/docs/filtering.rst b/docs/filtering.rst index 06e4f06..6737933 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -126,7 +126,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: @@ -217,4 +217,4 @@ with this set up, you can now order the users under group: xxx } } - } \ No newline at end of file + } 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"] diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 897acc7..6841e69 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 from django.utils.functional import Promise @@ -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: @@ -235,12 +242,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) diff --git a/graphene_django/fields.py b/graphene_django/fields.py index ce85a44..d3eaaff 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -1,14 +1,14 @@ from functools import partial from django.db.models.query import QuerySet -from graphene import NonNull from graphene.relay.connection import page_info_adapter, connection_adapter +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 -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 @@ -16,19 +16,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/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index f9b8fa0..4a683a3 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 @@ -837,6 +835,75 @@ 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( + """\ + \"""An object with an ID\""" + interface Node { + \"""The ID of the object\""" + id: ID! + } + + \""" + The Relay compliant `PageInfo` type, containing data necessary to paginate this connection. + \""" + type PageInfo { + \"""When paginating forwards, are there more items?\""" + hasNextPage: Boolean! + + \"""When paginating backwards, are there more items?\""" + hasPreviousPage: Boolean! + + \"""When paginating backwards, the cursor to continue.\""" + startCursor: String + + \"""When paginating forwards, the cursor to continue.\""" + endCursor: String + } + + type PetType implements Node { + age: Int! + + \"""The ID of the object\""" + id: ID! + } + + type PetTypeConnection { + \"""Pagination data for this connection.\""" + pageInfo: PageInfo! + + \"""Contains the nodes in this connection.\""" + edges: [PetTypeEdge]! + } + + \"""A Relay edge containing a `PetType` and its cursor.\""" + type PetTypeEdge { + \"""The item at the end of the edge\""" + node: PetType + + \"""A cursor for use in pagination\""" + cursor: String! + } + + type Query { + pets(before: String = null, after: String = null, first: Int = null, last: Int = null, age: Int = null, age_Isnull: Boolean = null, age_Lt: Int = null): 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"): diff --git a/graphene_django/rest_framework/serializer_converter.py b/graphene_django/rest_framework/serializer_converter.py index c229522..91d82a5 100644 --- a/graphene_django/rest_framework/serializer_converter.py +++ b/graphene_django/rest_framework/serializer_converter.py @@ -5,6 +5,7 @@ from functools import singledispatch import graphene from ..registry import get_global_registry +from ..converter import convert_choices_to_named_enum_with_descriptions from .types import DictType @@ -128,7 +129,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) @@ -143,5 +143,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) 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": []}, + ] + } diff --git a/graphene_django/views.py b/graphene_django/views.py index 160d912..e2aa688 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -52,7 +52,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"