From d9ab8acf26f29033ff050f90cbcd194c2821f31c Mon Sep 17 00:00:00 2001 From: Jason Kraus Date: Tue, 2 Feb 2021 09:58:21 -0800 Subject: [PATCH 01/52] document auth pattern: return None with resolve method (#1106) * document auth pattern: return None with resolve method * (doc, auth): also show that one can raise an exception in a resolve method --- docs/authorization.rst | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/docs/authorization.rst b/docs/authorization.rst index 387ad29..39305f6 100644 --- a/docs/authorization.rst +++ b/docs/authorization.rst @@ -48,6 +48,31 @@ conversely you can use ``exclude`` meta attribute. exclude = ('published', 'owner') interfaces = (relay.Node, ) + +Another pattern is to have a resolve method act as a gatekeeper, returning None +or raising an exception if the client isn't allowed to see the data. + +.. code:: python + + from graphene import relay + from graphene_django.types import DjangoObjectType + from .models import Post + + class PostNode(DjangoObjectType): + class Meta: + model = Post + fields = ('title', 'content', 'owner') + interfaces = (relay.Node, ) + + def resolve_owner(self, info): + user = info.context.user + if user.is_anonymous: + raise PermissionDenied("Please login") + if not user.is_staff: + return None + return self.owner + + Queryset Filtering On Lists --------------------------- From c3404a979376a87955f91fc077a802d82d1f2c3b Mon Sep 17 00:00:00 2001 From: Jason Kraus Date: Tue, 2 Feb 2021 09:58:43 -0800 Subject: [PATCH 02/52] document purpose of DjangoConnectionField (#1107) --- docs/fields.rst | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/fields.rst b/docs/fields.rst index 32ca26c..f414a4c 100644 --- a/docs/fields.rst +++ b/docs/fields.rst @@ -80,4 +80,6 @@ published and have a title: DjangoConnectionField --------------------- -*TODO* +``DjangoConnectionField`` acts similarly to ``DjangoListField`` but returns a +paginated connection following the `relay spec `__ +The field supports the following arguments: `first`, `last`, `offset`, `after` & `before`. From 4573d3db53529fe7ae17a0d342ff626158eb23b6 Mon Sep 17 00:00:00 2001 From: Jason Kraus Date: Sat, 20 Feb 2021 14:26:06 -0800 Subject: [PATCH 03/52] Fix test main (#1126) Co-authored-by: Thomas Leonard --- graphene_django/filter/tests/test_range_filter.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/graphene_django/filter/tests/test_range_filter.py b/graphene_django/filter/tests/test_range_filter.py index 995f588..644ec5d 100644 --- a/graphene_django/filter/tests/test_range_filter.py +++ b/graphene_django/filter/tests/test_range_filter.py @@ -101,14 +101,14 @@ def test_range_filter_with_invalid_input(): # Empty list result = schema.execute(query, variables={"rangeValue": []}) assert len(result.errors) == 1 - assert result.errors[0].message == f"['{expected_error}']" + assert result.errors[0].message == expected_error # Only one item in the list result = schema.execute(query, variables={"rangeValue": [1]}) assert len(result.errors) == 1 - assert result.errors[0].message == f"['{expected_error}']" + assert result.errors[0].message == expected_error # More than 2 items in the list result = schema.execute(query, variables={"rangeValue": [1, 2, 3]}) assert len(result.errors) == 1 - assert result.errors[0].message == f"['{expected_error}']" + assert result.errors[0].message == expected_error From 52880166bd5f778604f1df7b58d4c2111faaea4e Mon Sep 17 00:00:00 2001 From: Tonye Jack Date: Mon, 22 Feb 2021 23:10:30 -0500 Subject: [PATCH 04/52] Remove unused imports (#1127) * Remove unused imports * Update converter.py --- graphene_django/converter.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 1da68d5..525bb11 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -1,5 +1,5 @@ from collections import OrderedDict -from functools import singledispatch, partial, wraps +from functools import singledispatch, wraps from django.db import models from django.utils.encoding import force_str @@ -23,7 +23,6 @@ from graphene import ( Time, Decimal, ) -from graphene.types.resolver import get_default_resolver from graphene.types.json import JSONString from graphene.utils.str_converters import to_camel_case from graphql import GraphQLError, assert_valid_name From beb2e4aae324cd89038aabf9718c01a3607bc1a1 Mon Sep 17 00:00:00 2001 From: Jiahao Li Date: Mon, 22 Feb 2021 23:13:49 -0500 Subject: [PATCH 05/52] Doc clarification for headers arg in testing utils (#1117) I think it might be helpful to add an explicit hint that HTTP headers should be prepended with `HTTP_` as required by `django.test.Client` (at least it was not super obvious to me when I tried to use it). --- graphene_django/utils/testing.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/graphene_django/utils/testing.py b/graphene_django/utils/testing.py index 584a08b..6187566 100644 --- a/graphene_django/utils/testing.py +++ b/graphene_django/utils/testing.py @@ -28,7 +28,9 @@ def graphql_query( 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. + will be set to this value. Keys should be prepended with + "HTTP_" (e.g. to specify the "Authorization" HTTP header, + use "HTTP_AUTHORIZATION" as the key). client (django.test.Client) - Test client. Defaults to django.test.Client. graphql_url (string) - URL to graphql endpoint. Defaults to "/graphql". @@ -85,7 +87,9 @@ class GraphQLTestCase(TestCase): 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. + will be set to this value. Keys should be prepended with + "HTTP_" (e.g. to specify the "Authorization" HTTP header, + use "HTTP_AUTHORIZATION" as the key). Returns: Response object from client From 007768b4544318635a172d28eeb08b0f830d7cc2 Mon Sep 17 00:00:00 2001 From: Yves-Gwenael Bourhis <1560334+ygbourhis@users.noreply.github.com> Date: Tue, 23 Feb 2021 05:19:20 +0100 Subject: [PATCH 06/52] Fix subscriptions in JS (#1124) --- graphene_django/static/graphene_django/graphiql.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/static/graphene_django/graphiql.js b/graphene_django/static/graphene_django/graphiql.js index 8c3b5ce..ac010e8 100644 --- a/graphene_django/static/graphene_django/graphiql.js +++ b/graphene_django/static/graphene_django/graphiql.js @@ -123,8 +123,8 @@ if (operationType === "subscription") { return { subscribe: function (observer) { - subscriptionClient.request(graphQLParams).subscribe(observer); activeSubscription = subscriptionClient; + return subscriptionClient.request(graphQLParams, opts).subscribe(observer); }, }; } else { From 5ce45532442fb6b131b2ebffd27f694cdc81efb9 Mon Sep 17 00:00:00 2001 From: andrei-datcu Date: Tue, 23 Feb 2021 06:20:59 +0200 Subject: [PATCH 07/52] Fix schema dump on windows (#1123) Without explicitly setting the encoding to "utf-8" I get the following error on windows (python 3.9) ``` File "D:\env\lib\site-packages\graphene_django\management\commands\graphql_schema.py", line 115, in handle self.get_schema(schema, out, indent) File "D:\env\lib\site-packages\graphene_django\management\commands\graphql_schema.py", line 72, in get_schema self.save_graphql_file(out, schema) File "D:\env\lib\site-packages\graphene_django\management\commands\graphql_schema.py", line 59, in save_graphql_file outfile.write(print_schema(schema.graphql_schema)) File "C:\Users\u\AppData\Local\Programs\Python\Python39\lib\encodings\cp1252.py", line 19, in encode return codecs.charmap_encode(input,self.errors,encoding_table)[0] ``` --- graphene_django/management/commands/graphql_schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/management/commands/graphql_schema.py b/graphene_django/management/commands/graphql_schema.py index babbf05..565f5d8 100644 --- a/graphene_django/management/commands/graphql_schema.py +++ b/graphene_django/management/commands/graphql_schema.py @@ -55,7 +55,7 @@ class Command(CommandArguments): json.dump(schema_dict, outfile, indent=indent, sort_keys=True) def save_graphql_file(self, out, schema): - with open(out, "w") as outfile: + with open(out, "w", encoding="utf-8") as outfile: outfile.write(print_schema(schema.graphql_schema)) def get_schema(self, schema, out, indent): From 2d4ca0ac7b60413a7c5d9a5a995ad478c05d6352 Mon Sep 17 00:00:00 2001 From: Thomas Leonard <64223923+tcleonard@users.noreply.github.com> Date: Tue, 23 Feb 2021 05:21:32 +0100 Subject: [PATCH 08/52] Add enum support to filters and fix filter typing (v3) (#1119) * - Add filtering support for choice fields converted to graphql Enum (or not) - Fix type of various filters (used to default to String) - Fix bug with contains introduced in previous PR - Fix bug with declared filters being overridden (see PR #1108) - Fix support for ArrayField and add documentation * Fix for v3 Co-authored-by: Thomas Leonard --- docs/filtering.rst | 43 +++ graphene_django/converter.py | 2 +- graphene_django/filter/__init__.py | 11 +- graphene_django/filter/fields.py | 27 +- graphene_django/filter/filters.py | 32 +- graphene_django/filter/tests/conftest.py | 59 ++- graphene_django/filter/tests/filters.py | 2 +- ...py => test_array_field_contains_filter.py} | 19 +- .../tests/test_array_field_exact_filter.py | 129 +++++++ ....py => test_array_field_overlap_filter.py} | 12 +- .../filter/tests/test_enum_filtering.py | 160 ++++++++ graphene_django/filter/tests/test_fields.py | 53 ++- .../filter/tests/test_in_filter.py | 349 +++++++++++++++--- .../filter/tests/test_range_filter.py | 1 + graphene_django/filter/utils.py | 129 +++++-- graphene_django/tests/models.py | 6 +- graphene_django/tests/test_query.py | 7 + graphene_django/tests/test_types.py | 1 + 18 files changed, 916 insertions(+), 126 deletions(-) rename graphene_django/filter/tests/{test_contains_filter.py => test_array_field_contains_filter.py} (74%) create mode 100644 graphene_django/filter/tests/test_array_field_exact_filter.py rename graphene_django/filter/tests/{test_overlap_filter.py => test_array_field_overlap_filter.py} (84%) create mode 100644 graphene_django/filter/tests/test_enum_filtering.py diff --git a/docs/filtering.rst b/docs/filtering.rst index 6a57bf9..beb5e5b 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -258,3 +258,46 @@ with this set up, you can now order the users under group: } } } + + +PostgreSQL `ArrayField` +----------------------- + +Graphene provides an easy to implement filters on `ArrayField` as they are not natively supported by django_filters: + +.. code:: python + + from django.db import models + from django_filters import FilterSet, OrderingFilter + from graphene_django.filter import ArrayFilter + + class Event(models.Model): + name = models.CharField(max_length=50) + tags = ArrayField(models.CharField(max_length=50)) + + class EventFilterSet(FilterSet): + class Meta: + model = Event + fields = { + "name": ["exact", "contains"], + } + + tags__contains = ArrayFilter(field_name="tags", lookup_expr="contains") + tags__overlap = ArrayFilter(field_name="tags", lookup_expr="overlap") + tags = ArrayFilter(field_name="tags", lookup_expr="exact") + + class EventType(DjangoObjectType): + class Meta: + model = Event + interfaces = (Node,) + filterset_class = EventFilterSet + +with this set up, you can now filter events by tags: + +.. code:: + + query { + events(tags_Overlap: ["concert", "festival"]) { + name + } + } diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 525bb11..6bbf534 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -35,7 +35,7 @@ from .utils.str_converters import to_const class BlankValueField(Field): - def get_resolver(self, parent_resolver): + def wrap_resolve(self, parent_resolver): resolver = self.resolver or parent_resolver # create custom resolver diff --git a/graphene_django/filter/__init__.py b/graphene_django/filter/__init__.py index 5de36ad..94570c9 100644 --- a/graphene_django/filter/__init__.py +++ b/graphene_django/filter/__init__.py @@ -9,10 +9,19 @@ if not DJANGO_FILTER_INSTALLED: ) else: from .fields import DjangoFilterConnectionField - from .filters import GlobalIDFilter, GlobalIDMultipleChoiceFilter + from .filters import ( + ArrayFilter, + GlobalIDFilter, + GlobalIDMultipleChoiceFilter, + ListFilter, + RangeFilter, + ) __all__ = [ "DjangoFilterConnectionField", "GlobalIDFilter", "GlobalIDMultipleChoiceFilter", + "ArrayFilter", + "ListFilter", + "RangeFilter", ] diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py index 244fb39..c6dd50e 100644 --- a/graphene_django/filter/fields.py +++ b/graphene_django/filter/fields.py @@ -2,12 +2,31 @@ from collections import OrderedDict from functools import partial from django.core.exceptions import ValidationError + +from graphene.types.enum import EnumType from graphene.types.argument import to_arguments from graphene.utils.str_converters import to_snake_case + from ..fields import DjangoConnectionField from .utils import get_filtering_args_from_filterset, get_filterset_class +def convert_enum(data): + """ + Check if the data is a enum option (or potentially nested list of enum option) + and convert it to its value. + + This method is used to pre-process the data for the filters as they can take an + graphene.Enum as argument, but filters (from django_filters) expect a simple value. + """ + if isinstance(data, list): + return [convert_enum(item) for item in data] + if isinstance(type(data), EnumType): + return data.value + else: + return data + + class DjangoFilterConnectionField(DjangoConnectionField): def __init__( self, @@ -43,8 +62,8 @@ class DjangoFilterConnectionField(DjangoConnectionField): if self._extra_filter_meta: meta.update(self._extra_filter_meta) - filterset_class = self._provided_filterset_class or ( - self.node_type._meta.filterset_class + filterset_class = ( + self._provided_filterset_class or self.node_type._meta.filterset_class ) self._filterset_class = get_filterset_class(filterset_class, **meta) @@ -68,7 +87,7 @@ class DjangoFilterConnectionField(DjangoConnectionField): if k in filtering_args: if k == "order_by" and v is not None: v = to_snake_case(v) - kwargs[k] = v + kwargs[k] = convert_enum(v) return kwargs qs = super(DjangoFilterConnectionField, cls).resolve_queryset( @@ -78,7 +97,7 @@ class DjangoFilterConnectionField(DjangoConnectionField): filterset = filterset_class( data=filter_kwargs(), queryset=qs, request=info.context ) - if filterset.form.is_valid(): + if filterset.is_valid(): return filterset.qs raise ValidationError(filterset.form.errors.as_json()) diff --git a/graphene_django/filter/filters.py b/graphene_django/filter/filters.py index 58d7d08..e23626a 100644 --- a/graphene_django/filter/filters.py +++ b/graphene_django/filter/filters.py @@ -2,6 +2,7 @@ from django.core.exceptions import ValidationError from django.forms import Field from django_filters import Filter, MultipleChoiceFilter +from django_filters.constants import EMPTY_VALUES from graphql_relay.node.node import from_global_id @@ -31,14 +32,15 @@ class GlobalIDMultipleChoiceFilter(MultipleChoiceFilter): return super(GlobalIDMultipleChoiceFilter, self).filter(qs, gids) -class InFilter(Filter): +class ListFilter(Filter): """ - Filter for a list of value using the `__in` Django filter. + Filter that takes a list of value as input. + It is for example used for `__in` filters. """ def filter(self, qs, value): """ - Override the default filter class to check first weather the list is + Override the default filter class to check first whether the list is empty or not. This needs to be done as in this case we expect to get an empty output (if not an exclude filter) but django_filter consider an empty list @@ -73,3 +75,27 @@ class RangeField(Field): class RangeFilter(Filter): field_class = RangeField + + +class ArrayFilter(Filter): + """ + Filter made for PostgreSQL ArrayField. + """ + + def filter(self, qs, value): + """ + Override the default filter class to check first whether the list is + empty or not. + This needs to be done as in this case we expect to get the filter applied with + an empty list since it's a valid value but django_filter consider an empty list + to be an empty input value (see `EMPTY_VALUES`) meaning that + the filter does not need to be applied (hence returning the original + queryset). + """ + if value in EMPTY_VALUES and value != []: + return qs + if self.distinct: + qs = qs.distinct() + lookup = "%s__%s" % (self.field_name, self.lookup_expr) + qs = self.get_method(qs)(**{lookup: value}) + return qs diff --git a/graphene_django/filter/tests/conftest.py b/graphene_django/filter/tests/conftest.py index 0313645..57924af 100644 --- a/graphene_django/filter/tests/conftest.py +++ b/graphene_django/filter/tests/conftest.py @@ -9,6 +9,7 @@ import graphene from graphene.relay import Node from graphene_django import DjangoObjectType from graphene_django.utils import DJANGO_FILTER_INSTALLED +from graphene_django.filter import ArrayFilter, ListFilter from ...compat import ArrayField @@ -27,49 +28,61 @@ else: STORE = {"events": []} -@pytest.fixture -def Event(): - class Event(models.Model): - name = models.CharField(max_length=50) - tags = ArrayField(models.CharField(max_length=50)) - - return Event +class Event(models.Model): + name = models.CharField(max_length=50) + tags = ArrayField(models.CharField(max_length=50)) + tag_ids = ArrayField(models.IntegerField()) + random_field = ArrayField(models.BooleanField()) @pytest.fixture -def EventFilterSet(Event): - - from django.contrib.postgres.forms import SimpleArrayField - - class ArrayFilter(filters.Filter): - base_field_class = SimpleArrayField - +def EventFilterSet(): class EventFilterSet(FilterSet): class Meta: model = Event fields = { - "name": ["exact"], + "name": ["exact", "contains"], } + # Those are actually usable with our Query fixture bellow tags__contains = ArrayFilter(field_name="tags", lookup_expr="contains") tags__overlap = ArrayFilter(field_name="tags", lookup_expr="overlap") + tags = ArrayFilter(field_name="tags", lookup_expr="exact") + + # Those are actually not usable and only to check type declarations + tags_ids__contains = ArrayFilter(field_name="tag_ids", lookup_expr="contains") + tags_ids__overlap = ArrayFilter(field_name="tag_ids", lookup_expr="overlap") + tags_ids = ArrayFilter(field_name="tag_ids", lookup_expr="exact") + random_field__contains = ArrayFilter( + field_name="random_field", lookup_expr="contains" + ) + random_field__overlap = ArrayFilter( + field_name="random_field", lookup_expr="overlap" + ) + random_field = ArrayFilter(field_name="random_field", lookup_expr="exact") return EventFilterSet @pytest.fixture -def EventType(Event, EventFilterSet): +def EventType(EventFilterSet): class EventType(DjangoObjectType): class Meta: model = Event interfaces = (Node,) + fields = "__all__" filterset_class = EventFilterSet return EventType @pytest.fixture -def Query(Event, EventType): +def Query(EventType): + """ + Note that we have to use a custom resolver to replicate the arrayfield filter behavior as + we are running unit tests in sqlite which does not have ArrayFields. + """ + class Query(graphene.ObjectType): events = DjangoFilterConnectionField(EventType) @@ -79,6 +92,7 @@ def Query(Event, EventType): Event(name="Live Show", tags=["concert", "music", "rock"],), Event(name="Musical", tags=["movie", "music"],), Event(name="Ballet", tags=["concert", "dance"],), + Event(name="Speech", tags=[],), ] STORE["events"] = events @@ -105,6 +119,13 @@ def Query(Event, EventType): STORE["events"], ) ) + if "tags__exact" in kwargs: + STORE["events"] = list( + filter( + lambda e: set(kwargs["tags__exact"]) == set(e.tags), + STORE["events"], + ) + ) def mock_queryset_filter(*args, **kwargs): filter_events(**kwargs) @@ -121,7 +142,9 @@ def Query(Event, EventType): m_queryset.filter.side_effect = mock_queryset_filter m_queryset.none.side_effect = mock_queryset_none m_queryset.count.side_effect = mock_queryset_count - m_queryset.__getitem__.side_effect = STORE["events"].__getitem__ + m_queryset.__getitem__.side_effect = lambda index: STORE[ + "events" + ].__getitem__(index) return m_queryset diff --git a/graphene_django/filter/tests/filters.py b/graphene_django/filter/tests/filters.py index 43b6a87..a7443c0 100644 --- a/graphene_django/filter/tests/filters.py +++ b/graphene_django/filter/tests/filters.py @@ -10,7 +10,7 @@ class ArticleFilter(django_filters.FilterSet): fields = { "headline": ["exact", "icontains"], "pub_date": ["gt", "lt", "exact"], - "reporter": ["exact"], + "reporter": ["exact", "in"], } order_by = OrderingFilter(fields=("pub_date",)) diff --git a/graphene_django/filter/tests/test_contains_filter.py b/graphene_django/filter/tests/test_array_field_contains_filter.py similarity index 74% rename from graphene_django/filter/tests/test_contains_filter.py rename to graphene_django/filter/tests/test_array_field_contains_filter.py index 35e775e..4144614 100644 --- a/graphene_django/filter/tests/test_contains_filter.py +++ b/graphene_django/filter/tests/test_array_field_contains_filter.py @@ -6,9 +6,9 @@ from ...compat import ArrayField, MissingType @pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist") -def test_string_contains_multiple(Query): +def test_array_field_contains_multiple(Query): """ - Test contains filter on a string field. + Test contains filter on a array field of string. """ schema = Schema(query=Query) @@ -32,9 +32,9 @@ def test_string_contains_multiple(Query): @pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist") -def test_string_contains_one(Query): +def test_array_field_contains_one(Query): """ - Test contains filter on a string field. + Test contains filter on a array field of string. """ schema = Schema(query=Query) @@ -59,9 +59,9 @@ def test_string_contains_one(Query): @pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist") -def test_string_contains_none(Query): +def test_array_field_contains_empty_list(Query): """ - Test contains filter on a string field. + Test contains filter on a array field of string. """ schema = Schema(query=Query) @@ -79,4 +79,9 @@ def test_string_contains_none(Query): """ result = schema.execute(query) assert not result.errors - assert result.data["events"]["edges"] == [] + assert result.data["events"]["edges"] == [ + {"node": {"name": "Live Show"}}, + {"node": {"name": "Musical"}}, + {"node": {"name": "Ballet"}}, + {"node": {"name": "Speech"}}, + ] diff --git a/graphene_django/filter/tests/test_array_field_exact_filter.py b/graphene_django/filter/tests/test_array_field_exact_filter.py new file mode 100644 index 0000000..b07abed --- /dev/null +++ b/graphene_django/filter/tests/test_array_field_exact_filter.py @@ -0,0 +1,129 @@ +import pytest + +from graphene import Schema + +from ...compat import ArrayField, MissingType + + +@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist") +def test_array_field_exact_no_match(Query): + """ + Test exact filter on a array field of string. + """ + + schema = Schema(query=Query) + + query = """ + query { + events (tags: ["concert", "music"]) { + edges { + node { + name + } + } + } + } + """ + result = schema.execute(query) + assert not result.errors + assert result.data["events"]["edges"] == [] + + +@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist") +def test_array_field_exact_match(Query): + """ + Test exact filter on a array field of string. + """ + + schema = Schema(query=Query) + + query = """ + query { + events (tags: ["movie", "music"]) { + edges { + node { + name + } + } + } + } + """ + result = schema.execute(query) + assert not result.errors + assert result.data["events"]["edges"] == [ + {"node": {"name": "Musical"}}, + ] + + +@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist") +def test_array_field_exact_empty_list(Query): + """ + Test exact filter on a array field of string. + """ + + schema = Schema(query=Query) + + query = """ + query { + events (tags: []) { + edges { + node { + name + } + } + } + } + """ + result = schema.execute(query) + assert not result.errors + assert result.data["events"]["edges"] == [ + {"node": {"name": "Speech"}}, + ] + + +def test_array_field_filter_schema_type(Query): + """ + Check that the type in the filter is an array field like on the object type. + """ + schema = Schema(query=Query) + schema_str = str(schema) + + assert ( + '''type EventType implements Node { + """The ID of the object""" + id: ID! + name: String! + tags: [String!]! + tagIds: [Int!]! + randomField: [Boolean!]! +}''' + in schema_str + ) + + filters = { + "offset": "Int", + "before": "String", + "after": "String", + "first": "Int", + "last": "Int", + "name": "String", + "name_Contains": "String", + "tags_Contains": "[String!]", + "tags_Overlap": "[String!]", + "tags": "[String!]", + "tagsIds_Contains": "[Int!]", + "tagsIds_Overlap": "[Int!]", + "tagsIds": "[Int!]", + "randomField_Contains": "[Boolean!]", + "randomField_Overlap": "[Boolean!]", + "randomField": "[Boolean!]", + } + filters_str = ", ".join( + [ + f"{filter_field}: {gql_type} = null" + for filter_field, gql_type in filters.items() + ] + ) + assert ( + f"type Query {{\n events({filters_str}): EventTypeConnection\n}}" in schema_str + ) diff --git a/graphene_django/filter/tests/test_overlap_filter.py b/graphene_django/filter/tests/test_array_field_overlap_filter.py similarity index 84% rename from graphene_django/filter/tests/test_overlap_filter.py rename to graphene_django/filter/tests/test_array_field_overlap_filter.py index 32dfa44..5ce1576 100644 --- a/graphene_django/filter/tests/test_overlap_filter.py +++ b/graphene_django/filter/tests/test_array_field_overlap_filter.py @@ -6,9 +6,9 @@ from ...compat import ArrayField, MissingType @pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist") -def test_string_overlap_multiple(Query): +def test_array_field_overlap_multiple(Query): """ - Test overlap filter on a string field. + Test overlap filter on a array field of string. """ schema = Schema(query=Query) @@ -34,9 +34,9 @@ def test_string_overlap_multiple(Query): @pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist") -def test_string_overlap_one(Query): +def test_array_field_overlap_one(Query): """ - Test overlap filter on a string field. + Test overlap filter on a array field of string. """ schema = Schema(query=Query) @@ -61,9 +61,9 @@ def test_string_overlap_one(Query): @pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist") -def test_string_overlap_none(Query): +def test_array_field_overlap_empty_list(Query): """ - Test overlap filter on a string field. + Test overlap filter on a array field of string. """ schema = Schema(query=Query) diff --git a/graphene_django/filter/tests/test_enum_filtering.py b/graphene_django/filter/tests/test_enum_filtering.py new file mode 100644 index 0000000..09c69b3 --- /dev/null +++ b/graphene_django/filter/tests/test_enum_filtering.py @@ -0,0 +1,160 @@ +import pytest + +import graphene +from graphene.relay import Node + +from graphene_django import DjangoObjectType, DjangoConnectionField +from graphene_django.tests.models import Article, Reporter +from graphene_django.utils import DJANGO_FILTER_INSTALLED + +pytestmark = [] + +if DJANGO_FILTER_INSTALLED: + from graphene_django.filter import DjangoFilterConnectionField +else: + pytestmark.append( + pytest.mark.skipif( + True, reason="django_filters not installed or not compatible" + ) + ) + + +@pytest.fixture +def schema(): + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + fields = "__all__" + + class ArticleType(DjangoObjectType): + class Meta: + model = Article + interfaces = (Node,) + fields = "__all__" + filter_fields = { + "lang": ["exact", "in"], + "reporter__a_choice": ["exact", "in"], + } + + class Query(graphene.ObjectType): + all_reporters = DjangoConnectionField(ReporterType) + all_articles = DjangoFilterConnectionField(ArticleType) + + schema = graphene.Schema(query=Query) + return schema + + +@pytest.fixture +def reporter_article_data(): + john = Reporter.objects.create( + first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 + ) + jane = Reporter.objects.create( + first_name="Jane", last_name="Doe", email="janedoe@example.com", a_choice=2 + ) + Article.objects.create( + headline="Article Node 1", reporter=john, editor=john, lang="es", + ) + Article.objects.create( + headline="Article Node 2", reporter=john, editor=john, lang="en", + ) + Article.objects.create( + headline="Article Node 3", reporter=jane, editor=jane, lang="en", + ) + + +def test_filter_enum_on_connection(schema, reporter_article_data): + """ + Check that we can filter with enums on a connection. + """ + query = """ + query { + allArticles(lang: ES) { + edges { + node { + headline + } + } + } + } + """ + + expected = {"allArticles": {"edges": [{"node": {"headline": "Article Node 1"}},]}} + + result = schema.execute(query) + assert not result.errors + assert result.data == expected + + +def test_filter_on_foreign_key_enum_field(schema, reporter_article_data): + """ + Check that we can filter with enums on a field from a foreign key. + """ + query = """ + query { + allArticles(reporter_AChoice: A_1) { + edges { + node { + headline + } + } + } + } + """ + + expected = { + "allArticles": { + "edges": [ + {"node": {"headline": "Article Node 1"}}, + {"node": {"headline": "Article Node 2"}}, + ] + } + } + + result = schema.execute(query) + assert not result.errors + assert result.data == expected + + +def test_filter_enum_field_schema_type(schema): + """ + Check that the type in the filter is an enum like on the object type. + """ + schema_str = str(schema) + + assert ( + '''type ArticleType implements Node { + """The ID of the object""" + id: ID! + headline: String! + pubDate: Date! + pubDateTime: DateTime! + reporter: ReporterType! + editor: ReporterType! + + """Language""" + lang: TestsArticleLangChoices! + importance: TestsArticleImportanceChoices +}''' + in schema_str + ) + + filters = { + "offset": "Int", + "before": "String", + "after": "String", + "first": "Int", + "last": "Int", + "lang": "TestsArticleLangChoices", + "lang_In": "[TestsArticleLangChoices]", + "reporter_AChoice": "TestsReporterAChoiceChoices", + "reporter_AChoice_In": "[TestsReporterAChoiceChoices]", + } + filters_str = ", ".join( + [ + f"{filter_field}: {gql_type} = null" + for filter_field, gql_type in filters.items() + ] + ) + assert f" allArticles({filters_str}): ArticleTypeConnection\n" in schema_str diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index 9c94f06..274f6ac 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -9,7 +9,7 @@ from graphene import Argument, Boolean, Field, Float, ObjectType, Schema, String from graphene.relay import Node from graphene_django import DjangoObjectType from graphene_django.forms import GlobalIDFormField, GlobalIDMultipleChoiceField -from graphene_django.tests.models import Article, Pet, Reporter +from graphene_django.tests.models import Article, Person, Pet, Reporter from graphene_django.utils import DJANGO_FILTER_INSTALLED pytestmark = [] @@ -90,6 +90,7 @@ def test_filter_explicit_filterset_arguments(): "pub_date__gt", "pub_date__lt", "reporter", + "reporter__in", ) @@ -696,7 +697,7 @@ def test_should_query_filter_node_limit(): node { id firstName - articles(lang: "es") { + articles(lang: ES) { edges { node { id @@ -738,6 +739,7 @@ def test_order_by(): class Meta: model = Reporter interfaces = (Node,) + fields = "__all__" class Query(ObjectType): all_reporters = DjangoFilterConnectionField( @@ -1143,7 +1145,7 @@ def test_filter_filterset_based_on_mixin(): return filters - def filter_email_in(cls, queryset, name, value): + def filter_email_in(self, queryset, name, value): return queryset.filter(**{name: [value]}) class NewArticleFilter(ArticleFilterMixin, ArticleFilter): @@ -1228,3 +1230,48 @@ def test_filter_filterset_based_on_mixin(): assert not result.errors assert result.data == expected + + +def test_filter_string_contains(): + class PersonType(DjangoObjectType): + class Meta: + model = Person + interfaces = (Node,) + fields = "__all__" + filter_fields = {"name": ["exact", "in", "contains", "icontains"]} + + class Query(ObjectType): + people = DjangoFilterConnectionField(PersonType) + + schema = Schema(query=Query) + + Person.objects.bulk_create( + [ + Person(name="Jack"), + Person(name="Joe"), + Person(name="Jane"), + Person(name="Peter"), + Person(name="Bob"), + ] + ) + query = """query nameContain($filter: String) { + people(name_Contains: $filter) { + edges { + node { + name + } + } + } + }""" + + result = schema.execute(query, variables={"filter": "Ja"}) + assert not result.errors + assert result.data == { + "people": {"edges": [{"node": {"name": "Jack"}}, {"node": {"name": "Jane"}},]} + } + + result = schema.execute(query, variables={"filter": "o"}) + assert not result.errors + assert result.data == { + "people": {"edges": [{"node": {"name": "Joe"}}, {"node": {"name": "Bob"}},]} + } diff --git a/graphene_django/filter/tests/test_in_filter.py b/graphene_django/filter/tests/test_in_filter.py index 9e9c323..7ad0286 100644 --- a/graphene_django/filter/tests/test_in_filter.py +++ b/graphene_django/filter/tests/test_in_filter.py @@ -1,3 +1,5 @@ +from datetime import datetime + import pytest from django_filters import FilterSet @@ -5,7 +7,8 @@ from django_filters import rest_framework as filters from graphene import ObjectType, Schema from graphene.relay import Node from graphene_django import DjangoObjectType -from graphene_django.tests.models import Pet, Person +from graphene_django.tests.models import Pet, Person, Reporter, Article, Film +from graphene_django.filter.tests.filters import ArticleFilter from graphene_django.utils import DJANGO_FILTER_INSTALLED pytestmark = [] @@ -20,40 +23,77 @@ else: ) -class PetNode(DjangoObjectType): - class Meta: - model = Pet - interfaces = (Node,) - filter_fields = { - "name": ["exact", "in"], - "age": ["exact", "in", "range"], - } +@pytest.fixture +def query(): + class PetNode(DjangoObjectType): + class Meta: + model = Pet + interfaces = (Node,) + fields = "__all__" + filter_fields = { + "id": ["exact", "in"], + "name": ["exact", "in"], + "age": ["exact", "in", "range"], + } + + class ReporterNode(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + fields = "__all__" + # choice filter using enum + filter_fields = {"reporter_type": ["exact", "in"]} + + class ArticleNode(DjangoObjectType): + class Meta: + model = Article + interfaces = (Node,) + fields = "__all__" + filterset_class = ArticleFilter + + class FilmNode(DjangoObjectType): + class Meta: + model = Film + interfaces = (Node,) + fields = "__all__" + # choice filter not using enum + filter_fields = { + "genre": ["exact", "in"], + } + convert_choices_to_enum = False + + class PersonFilterSet(FilterSet): + class Meta: + model = Person + fields = {"name": ["in"]} + + names = filters.BaseInFilter(method="filter_names") + + def filter_names(self, qs, name, value): + """ + This custom filter take a string as input with comma separated values. + Note that the value here is already a list as it has been transformed by the BaseInFilter class. + """ + return qs.filter(name__in=value) + + class PersonNode(DjangoObjectType): + class Meta: + model = Person + interfaces = (Node,) + filterset_class = PersonFilterSet + fields = "__all__" + + class Query(ObjectType): + pets = DjangoFilterConnectionField(PetNode) + people = DjangoFilterConnectionField(PersonNode) + articles = DjangoFilterConnectionField(ArticleNode) + films = DjangoFilterConnectionField(FilmNode) + reporters = DjangoFilterConnectionField(ReporterNode) + + return Query -class PersonFilterSet(FilterSet): - class Meta: - model = Person - fields = {} - - names = filters.BaseInFilter(method="filter_names") - - def filter_names(self, qs, name, value): - return qs.filter(name__in=value) - - -class PersonNode(DjangoObjectType): - class Meta: - model = Person - interfaces = (Node,) - filterset_class = PersonFilterSet - - -class Query(ObjectType): - pets = DjangoFilterConnectionField(PetNode) - people = DjangoFilterConnectionField(PersonNode) - - -def test_string_in_filter(): +def test_string_in_filter(query): """ Test in filter on a string field. """ @@ -61,7 +101,7 @@ def test_string_in_filter(): Pet.objects.create(name="Mimi", age=3) Pet.objects.create(name="Jojo, the rabbit", age=3) - schema = Schema(query=Query) + schema = Schema(query=query) query = """ query { @@ -82,17 +122,19 @@ def test_string_in_filter(): ] -def test_string_in_filter_with_filterset_class(): - """Test in filter on a string field with a custom filterset class.""" +def test_string_in_filter_with_otjer_filter(query): + """ + Test in filter on a string field which has also a custom filter doing a similar operation. + """ Person.objects.create(name="John") Person.objects.create(name="Michael") Person.objects.create(name="Angela") - schema = Schema(query=Query) + schema = Schema(query=query) query = """ query { - people (names: ["John", "Michael"]) { + people (name_In: ["John", "Michael"]) { edges { node { name @@ -109,7 +151,36 @@ def test_string_in_filter_with_filterset_class(): ] -def test_int_in_filter(): +def test_string_in_filter_with_declared_filter(query): + """ + Test in filter on a string field with a custom filterset class. + """ + Person.objects.create(name="John") + Person.objects.create(name="Michael") + Person.objects.create(name="Angela") + + schema = Schema(query=query) + + query = """ + query { + people (names: "John,Michael") { + edges { + node { + name + } + } + } + } + """ + result = schema.execute(query) + assert not result.errors + assert result.data["people"]["edges"] == [ + {"node": {"name": "John"}}, + {"node": {"name": "Michael"}}, + ] + + +def test_int_in_filter(query): """ Test in filter on an integer field. """ @@ -117,7 +188,7 @@ def test_int_in_filter(): Pet.objects.create(name="Mimi", age=3) Pet.objects.create(name="Jojo, the rabbit", age=3) - schema = Schema(query=Query) + schema = Schema(query=query) query = """ query { @@ -157,7 +228,7 @@ def test_int_in_filter(): ] -def test_in_filter_with_empty_list(): +def test_in_filter_with_empty_list(query): """ Check that using a in filter with an empty list provided as input returns no objects. """ @@ -165,7 +236,7 @@ def test_in_filter_with_empty_list(): Pet.objects.create(name="Mimi", age=8) Pet.objects.create(name="Picotin", age=5) - schema = Schema(query=Query) + schema = Schema(query=query) query = """ query { @@ -181,3 +252,197 @@ def test_in_filter_with_empty_list(): result = schema.execute(query) assert not result.errors assert len(result.data["pets"]["edges"]) == 0 + + +def test_choice_in_filter_without_enum(query): + """ + Test in filter o an choice field not using an enum (Film.genre). + """ + + john_doe = Reporter.objects.create( + first_name="John", last_name="Doe", email="john@doe.com" + ) + jean_bon = Reporter.objects.create( + first_name="Jean", last_name="Bon", email="jean@bon.com" + ) + documentary_film = Film.objects.create(genre="do") + documentary_film.reporters.add(john_doe) + action_film = Film.objects.create(genre="ac") + action_film.reporters.add(john_doe) + other_film = Film.objects.create(genre="ot") + other_film.reporters.add(john_doe) + other_film.reporters.add(jean_bon) + + schema = Schema(query=query) + + query = """ + query { + films (genre_In: ["do", "ac"]) { + edges { + node { + genre + reporters { + edges { + node { + lastName + } + } + } + } + } + } + } + """ + result = schema.execute(query) + assert not result.errors + assert result.data["films"]["edges"] == [ + { + "node": { + "genre": "do", + "reporters": {"edges": [{"node": {"lastName": "Doe"}}]}, + } + }, + { + "node": { + "genre": "ac", + "reporters": {"edges": [{"node": {"lastName": "Doe"}}]}, + } + }, + ] + + +def test_fk_id_in_filter(query): + """ + Test in filter on an foreign key relationship. + """ + john_doe = Reporter.objects.create( + first_name="John", last_name="Doe", email="john@doe.com" + ) + jean_bon = Reporter.objects.create( + first_name="Jean", last_name="Bon", email="jean@bon.com" + ) + sara_croche = Reporter.objects.create( + first_name="Sara", last_name="Croche", email="sara@croche.com" + ) + Article.objects.create( + headline="A", + pub_date=datetime.now(), + pub_date_time=datetime.now(), + reporter=john_doe, + editor=john_doe, + ) + Article.objects.create( + headline="B", + pub_date=datetime.now(), + pub_date_time=datetime.now(), + reporter=jean_bon, + editor=jean_bon, + ) + Article.objects.create( + headline="C", + pub_date=datetime.now(), + pub_date_time=datetime.now(), + reporter=sara_croche, + editor=sara_croche, + ) + + schema = Schema(query=query) + + query = """ + query { + articles (reporter_In: [%s, %s]) { + edges { + node { + headline + reporter { + lastName + } + } + } + } + } + """ % ( + john_doe.id, + jean_bon.id, + ) + result = schema.execute(query) + assert not result.errors + assert result.data["articles"]["edges"] == [ + {"node": {"headline": "A", "reporter": {"lastName": "Doe"}}}, + {"node": {"headline": "B", "reporter": {"lastName": "Bon"}}}, + ] + + +def test_enum_in_filter(query): + """ + Test in filter on a choice field using an enum (Reporter.reporter_type). + """ + + Reporter.objects.create( + first_name="John", last_name="Doe", email="john@doe.com", reporter_type=1 + ) + Reporter.objects.create( + first_name="Jean", last_name="Bon", email="jean@bon.com", reporter_type=2 + ) + Reporter.objects.create( + first_name="Jane", last_name="Doe", email="jane@doe.com", reporter_type=2 + ) + Reporter.objects.create( + first_name="Jack", last_name="Black", email="jack@black.com", reporter_type=None + ) + + schema = Schema(query=query) + + query = """ + query { + reporters (reporterType_In: [A_1]) { + edges { + node { + email + } + } + } + } + """ + result = schema.execute(query) + assert not result.errors + assert result.data["reporters"]["edges"] == [ + {"node": {"email": "john@doe.com"}}, + ] + + query = """ + query { + reporters (reporterType_In: [A_2]) { + edges { + node { + email + } + } + } + } + """ + result = schema.execute(query) + assert not result.errors + assert result.data["reporters"]["edges"] == [ + {"node": {"email": "jean@bon.com"}}, + {"node": {"email": "jane@doe.com"}}, + ] + + query = """ + query { + reporters (reporterType_In: [A_2, A_1]) { + edges { + node { + email + } + } + } + } + """ + result = schema.execute(query) + assert not result.errors + assert result.data["reporters"]["edges"] == [ + {"node": {"email": "john@doe.com"}}, + {"node": {"email": "jean@bon.com"}}, + {"node": {"email": "jane@doe.com"}}, + ] diff --git a/graphene_django/filter/tests/test_range_filter.py b/graphene_django/filter/tests/test_range_filter.py index 644ec5d..6227a70 100644 --- a/graphene_django/filter/tests/test_range_filter.py +++ b/graphene_django/filter/tests/test_range_filter.py @@ -25,6 +25,7 @@ class PetNode(DjangoObjectType): class Meta: model = Pet interfaces = (Node,) + fields = "__all__" filter_fields = { "name": ["exact", "in"], "age": ["exact", "in", "range"], diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index 4530599..d4fc1bf 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -1,53 +1,101 @@ import graphene -from django_filters.utils import get_model_field +from django import forms + +from django_filters.utils import get_model_field, get_field_parts from django_filters.filters import Filter, BaseCSVFilter from .filterset import custom_filterset_factory, setup_filterset -from .filters import InFilter, RangeFilter +from .filters import ArrayFilter, ListFilter, RangeFilter +from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField + + +def get_field_type(registry, model, field_name): + """ + Try to get a model field corresponding Graphql type from the DjangoObjectType. + """ + object_type = registry.get_type_for_model(model) + if object_type: + object_type_field = object_type._meta.fields.get(field_name) + if object_type_field: + field_type = object_type_field.type + if isinstance(field_type, graphene.NonNull): + field_type = field_type.of_type + return field_type + return None def get_filtering_args_from_filterset(filterset_class, type): - """ Inspect a FilterSet and produce the arguments to pass to - a Graphene Field. These arguments will be available to - filter against in the GraphQL + """ + Inspect a FilterSet and produce the arguments to pass to a Graphene Field. + These arguments will be available to filter against in the GraphQL API. """ from ..forms.converter import convert_form_field args = {} model = filterset_class._meta.model + registry = type._meta.registry for name, filter_field in filterset_class.base_filters.items(): - form_field = None filter_type = filter_field.lookup_expr + field_type = None + form_field = None - if name in filterset_class.declared_filters: - # Get the filter field from the explicitly declared filter - form_field = filter_field.field - field = convert_form_field(form_field) - else: - # Get the filter field with no explicit type declaration - model_field = get_model_field(model, filter_field.field_name) - if filter_type != "isnull" and hasattr(model_field, "formfield"): - form_field = model_field.formfield( - required=filter_field.extra.get("required", False) - ) + if ( + name not in filterset_class.declared_filters + or isinstance(filter_field, ListFilter) + or isinstance(filter_field, RangeFilter) + or isinstance(filter_field, ArrayFilter) + ): + # Get the filter field for filters that are no explicitly declared. - # Fallback to field defined on filter if we can't get it from the - # model field - if not form_field: - form_field = filter_field.field + required = filter_field.extra.get("required", False) + if filter_type == "isnull": + field = graphene.Boolean(required=required) + else: + model_field = get_model_field(model, filter_field.field_name) - field = convert_form_field(form_field) + # Get the form field either from: + # 1. the formfield corresponding to the model field + # 2. the field defined on filter + if hasattr(model_field, "formfield"): + form_field = model_field.formfield(required=required) + if not form_field: + form_field = filter_field.field - if filter_type in {"in", "range", "contains", "overlap"}: - # Replace CSV filters (`in`, `range`, `contains`, `overlap`) argument type to be a list of - # the same type as the field. See comments in - # `replace_csv_filters` method for more details. - field = graphene.List(field.get_type()) + # First try to get the matching field type from the GraphQL DjangoObjectType + if model_field: + if ( + isinstance(form_field, forms.ModelChoiceField) + or isinstance(form_field, forms.ModelMultipleChoiceField) + or isinstance(form_field, GlobalIDMultipleChoiceField) + or isinstance(form_field, GlobalIDFormField) + ): + # Foreign key have dynamic types and filtering on a foreign key actually means filtering on its ID. + field_type = get_field_type( + registry, model_field.related_model, "id" + ) + else: + field_type = get_field_type( + registry, model_field.model, model_field.name + ) - field_type = field.Argument() - field_type.description = str(filter_field.label) if filter_field.label else None - args[name] = field_type + if not field_type: + # Fallback on converting the form field either because: + # - it's an explicitly declared filters + # - we did not manage to get the type from the model type + form_field = form_field or filter_field.field + field_type = convert_form_field(form_field) + + if isinstance(filter_field, ListFilter) or isinstance( + filter_field, RangeFilter + ): + # Replace InFilter/RangeFilter filters (`in`, `range`) argument type to be a list of + # the same type as the field. See comments in `replace_csv_filters` method for more details. + field_type = graphene.List(field_type.get_type()) + + args[name] = graphene.Argument( + field_type.get_type(), description=filter_field.label, required=required, + ) return args @@ -69,18 +117,26 @@ def get_filterset_class(filterset_class, **meta): def replace_csv_filters(filterset_class): """ - Replace the "in", "contains", "overlap" and "range" filters (that are not explicitly declared) to not be BaseCSVFilter (BaseInFilter, BaseRangeFilter) objects anymore - but regular Filter objects that simply use the input value as filter argument on the queryset. + Replace the "in" and "range" filters (that are not explicitly declared) + to not be BaseCSVFilter (BaseInFilter, BaseRangeFilter) objects anymore + but our custom InFilter/RangeFilter filter class that use the input + value as filter argument on the queryset. - This is because those BaseCSVFilter are expecting a string as input with comma separated value but with GraphQl we - can actually have a list as input and have a proper type verification of each value in the list. + This is because those BaseCSVFilter are expecting a string as input with + comma separated values. + But with GraphQl we can actually have a list as input and have a proper + type verification of each value in the list. See issue https://github.com/graphql-python/graphene-django/issues/1068. """ for name, filter_field in list(filterset_class.base_filters.items()): + # Do not touch any declared filters + if name in filterset_class.declared_filters: + continue + filter_type = filter_field.lookup_expr - if filter_type in {"in", "contains", "overlap"}: - filterset_class.base_filters[name] = InFilter( + if filter_type == "in": + filterset_class.base_filters[name] = ListFilter( field_name=filter_field.field_name, lookup_expr=filter_field.lookup_expr, label=filter_field.label, @@ -88,7 +144,6 @@ def replace_csv_filters(filterset_class): exclude=filter_field.exclude, **filter_field.extra ) - elif filter_type == "range": filterset_class.base_filters[name] = RangeFilter( field_name=filter_field.field_name, diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py index 180acc5..7b76cd3 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -26,7 +26,7 @@ class Film(models.Model): genre = models.CharField( max_length=2, help_text="Genre", - choices=[("do", "Documentary"), ("ot", "Other")], + choices=[("do", "Documentary"), ("ac", "Action"), ("ot", "Other")], default="ot", ) reporters = models.ManyToManyField("Reporter", related_name="films") @@ -91,8 +91,8 @@ class CNNReporter(Reporter): class Article(models.Model): headline = models.CharField(max_length=100) - pub_date = models.DateField() - pub_date_time = models.DateTimeField() + pub_date = models.DateField(auto_now_add=True) + pub_date_time = models.DateTimeField(auto_now_add=True) reporter = models.ForeignKey( Reporter, on_delete=models.CASCADE, related_name="articles" ) diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 699814d..aabe19c 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -421,6 +421,7 @@ def test_should_query_node_filtering(): interfaces = (Node,) fields = "__all__" filter_fields = ("lang",) + convert_choices_to_enum = False class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) @@ -546,6 +547,7 @@ def test_should_query_node_multiple_filtering(): interfaces = (Node,) fields = "__all__" filter_fields = ("lang", "headline") + convert_choices_to_enum = False class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) @@ -1251,6 +1253,7 @@ class TestBackwardPagination: class Meta: model = Reporter interfaces = (Node,) + fields = "__all__" class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) @@ -1455,6 +1458,7 @@ def test_connection_should_enable_offset_filtering(): class Meta: model = Reporter interfaces = (Node,) + fields = "__all__" class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) @@ -1494,6 +1498,7 @@ def test_connection_should_enable_offset_filtering_higher_than_max_limit( class Meta: model = Reporter interfaces = (Node,) + fields = "__all__" class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) @@ -1527,6 +1532,7 @@ def test_connection_should_forbid_offset_filtering_with_before(): class Meta: model = Reporter interfaces = (Node,) + fields = "__all__" class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) @@ -1561,6 +1567,7 @@ def test_connection_should_allow_offset_filtering_with_after(): class Meta: model = Reporter interfaces = (Node,) + fields = "__all__" class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index cb653e1..bde72c7 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -671,6 +671,7 @@ def test_django_objecttype_name_connection_propagation(): class Meta: model = ReporterModel name = "CustomReporterName" + fields = "__all__" filter_fields = ["email"] interfaces = (Node,) From 5cee41407c10842a727f48482d31612d01ac31d6 Mon Sep 17 00:00:00 2001 From: Tonye Jack Date: Mon, 22 Feb 2021 23:24:02 -0500 Subject: [PATCH 09/52] Added GraphQLTransactionTestCase (#1099) * Added GraphQLTransactionTestCase - Adds support for testing code that is executed within a transaction Reference: https://docs.djangoproject.com/en/3.1/topics/testing/tools/#django.test.TransactionTestCase ``` For instance, you cannot test that a block of code is executing within a transaction, as is required when using select_for_update(). In those cases, you should use TransactionTestCase. ``` * Update testing.py * Update testing.py * Fixed formatting. * Updated docs. * Updated test. * Update testing.rst --- docs/testing.rst | 35 +++++++++++++++++++++++++++++ graphene_django/tests/test_utils.py | 2 +- graphene_django/utils/testing.py | 12 ++++++++-- 3 files changed, 46 insertions(+), 3 deletions(-) diff --git a/docs/testing.rst b/docs/testing.rst index c24f7c4..65b6f64 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -82,6 +82,41 @@ Usage: # Add some more asserts if you like ... + +For testing mutations that are executed within a transaction you should subclass `GraphQLTransactionTestCase` + +Usage: + +.. code:: python + + import json + + from graphene_django.utils.testing import GraphQLTransactionTestCase + + class MyFancyTransactionTestCase(GraphQLTransactionTestCase): + + def test_some_mutation_that_executes_within_a_transaction(self): + response = self.query( + ''' + mutation myMutation($input: MyMutationInput!) { + myMutation(input: $input) { + my-model { + id + name + } + } + } + ''', + op_name='myMutation', + input_data={'my_field': 'foo', 'other_field': 'bar'} + ) + + # This validates the status code and if you get errors + self.assertResponseNoErrors(response) + + # Add some more asserts if you like + ... + Using pytest ------------ diff --git a/graphene_django/tests/test_utils.py b/graphene_django/tests/test_utils.py index d895f46..adad00e 100644 --- a/graphene_django/tests/test_utils.py +++ b/graphene_django/tests/test_utils.py @@ -83,6 +83,6 @@ def client_query(client): def test_pytest_fixture_usage(client_query): - response = graphql_query("query { test }") + response = client_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 6187566..763196d 100644 --- a/graphene_django/utils/testing.py +++ b/graphene_django/utils/testing.py @@ -1,7 +1,7 @@ import json import warnings -from django.test import Client, TestCase +from django.test import Client, TestCase, TransactionTestCase DEFAULT_GRAPHQL_URL = "/graphql/" @@ -63,7 +63,7 @@ def graphql_query( return resp -class GraphQLTestCase(TestCase): +class GraphQLTestMixin(object): """ Based on: https://www.sam.today/blog/testing-graphql-with-graphene-django/ """ @@ -143,3 +143,11 @@ class GraphQLTestCase(TestCase): """ content = json.loads(resp.content) self.assertIn("errors", list(content.keys()), msg or content) + + +class GraphQLTestCase(GraphQLTestMixin, TestCase): + pass + + +class GraphQLTransactionTestCase(GraphQLTestMixin, TransactionTestCase): + pass From ea593b673fb88f16fea5b35d428d44d79583b88d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Sastoque=20H?= Date: Tue, 23 Feb 2021 18:50:19 +0100 Subject: [PATCH 10/52] Fix: Use resolver passed as an attribute (#1131) Co-authored-by: Sebastian Hernandez --- graphene_django/fields.py | 3 +- graphene_django/tests/test_fields.py | 92 ++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 8d6e995..4a85f57 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -61,12 +61,13 @@ class DjangoListField(Field): return queryset def wrap_resolve(self, parent_resolver): + resolver = super(DjangoListField, self).wrap_resolve(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, self.get_manager(), + self.list_resolver, django_object_type, resolver, self.get_manager(), ) diff --git a/graphene_django/tests/test_fields.py b/graphene_django/tests/test_fields.py index f68470e..835de78 100644 --- a/graphene_django/tests/test_fields.py +++ b/graphene_django/tests/test_fields.py @@ -408,3 +408,95 @@ class TestDjangoListField: {"firstName": "Debra", "articles": []}, ] } + + def test_resolve_list_external_resolver(self): + """Resolving a plain list from external resolver should work (and not call get_queryset)""" + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ("first_name", "articles") + + @classmethod + def get_queryset(cls, queryset, info): + # Only get reporters with at least 1 article + return queryset.annotate(article_count=Count("articles")).filter( + article_count__gt=0 + ) + + def resolve_reporters(_, info): + return [ReporterModel.objects.get(first_name="Debra")] + + class Query(ObjectType): + reporters = DjangoListField(Reporter, resolver=resolve_reporters) + + schema = Schema(query=Query) + + query = """ + query { + reporters { + firstName + } + } + """ + + r1 = ReporterModel.objects.create(first_name="Tara", last_name="West") + ReporterModel.objects.create(first_name="Debra", last_name="Payne") + + ArticleModel.objects.create( + headline="Amazing news", + reporter=r1, + pub_date=datetime.date.today(), + pub_date_time=datetime.datetime.now(), + editor=r1, + ) + + result = schema.execute(query) + + assert not result.errors + assert result.data == {"reporters": [{"firstName": "Debra"}]} + + def test_get_queryset_filter_external_resolver(self): + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ("first_name", "articles") + + @classmethod + def get_queryset(cls, queryset, info): + # Only get reporters with at least 1 article + return queryset.annotate(article_count=Count("articles")).filter( + article_count__gt=0 + ) + + def resolve_reporters(_, info): + return ReporterModel.objects.all() + + class Query(ObjectType): + reporters = DjangoListField(Reporter, resolver=resolve_reporters) + + schema = Schema(query=Query) + + query = """ + query { + reporters { + firstName + } + } + """ + + r1 = ReporterModel.objects.create(first_name="Tara", last_name="West") + ReporterModel.objects.create(first_name="Debra", last_name="Payne") + + ArticleModel.objects.create( + headline="Amazing news", + reporter=r1, + pub_date=datetime.date.today(), + pub_date_time=datetime.datetime.now(), + editor=r1, + ) + + result = schema.execute(query) + + assert not result.errors + assert result.data == {"reporters": [{"firstName": "Tara"}]} From 6046a710c887bf45a9480a162e20038ab5c95623 Mon Sep 17 00:00:00 2001 From: Thomas Leonard <64223923+tcleonard@users.noreply.github.com> Date: Fri, 26 Feb 2021 23:44:43 +0100 Subject: [PATCH 11/52] fix: declaration of required variable in filters v3 (#1137) * fix: declaration of required variable * Add unit test * Fix formating Co-authored-by: Thomas Leonard --- graphene_django/filter/tests/test_fields.py | 21 ++++++++++++++++++++- graphene_django/filter/utils.py | 3 +-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index 274f6ac..17b4630 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -16,7 +16,7 @@ pytestmark = [] if DJANGO_FILTER_INSTALLED: import django_filters - from django_filters import FilterSet, NumberFilter + from django_filters import FilterSet, NumberFilter, OrderingFilter from graphene_django.filter import ( GlobalIDFilter, @@ -1275,3 +1275,22 @@ def test_filter_string_contains(): assert result.data == { "people": {"edges": [{"node": {"name": "Joe"}}, {"node": {"name": "Bob"}},]} } + + +def test_only_custom_filters(): + class ReporterFilter(FilterSet): + class Meta: + model = Reporter + fields = [] + + some_filter = OrderingFilter(fields=("name",)) + + class ReporterFilterNode(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + fields = "__all__" + filterset_class = ReporterFilter + + field = DjangoFilterConnectionField(ReporterFilterNode) + assert_arguments(field, "some_filter") diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index d4fc1bf..9922cc8 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -37,6 +37,7 @@ def get_filtering_args_from_filterset(filterset_class, type): registry = type._meta.registry for name, filter_field in filterset_class.base_filters.items(): filter_type = filter_field.lookup_expr + required = filter_field.extra.get("required", False) field_type = None form_field = None @@ -47,8 +48,6 @@ def get_filtering_args_from_filterset(filterset_class, type): or isinstance(filter_field, ArrayFilter) ): # Get the filter field for filters that are no explicitly declared. - - required = filter_field.extra.get("required", False) if filter_type == "isnull": field = graphene.Boolean(required=required) else: From e9f25ecf2dfb53bb99a72d5116740f3565e81743 Mon Sep 17 00:00:00 2001 From: Jason Kraus Date: Tue, 2 Mar 2021 10:45:46 -0800 Subject: [PATCH 12/52] enhancement: DjangoDebugContext captures exceptions and allows captured stack traces to be queried (#1122) --- docs/debug.rst | 6 ++- graphene_django/debug/exception/__init__.py | 0 graphene_django/debug/exception/formating.py | 17 +++++++++ graphene_django/debug/exception/types.py | 10 +++++ graphene_django/debug/middleware.py | 13 ++++++- graphene_django/debug/tests/test_query.py | 39 ++++++++++++++++++++ graphene_django/debug/types.py | 4 ++ 7 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 graphene_django/debug/exception/__init__.py create mode 100644 graphene_django/debug/exception/formating.py create mode 100644 graphene_django/debug/exception/types.py diff --git a/docs/debug.rst b/docs/debug.rst index 2286519..1de52f1 100644 --- a/docs/debug.rst +++ b/docs/debug.rst @@ -4,7 +4,7 @@ Django Debug Middleware You can debug your GraphQL queries in a similar way to `django-debug-toolbar `__, but outputting in the results in GraphQL response as fields, instead of -the graphical HTML interface. +the graphical HTML interface. Exceptions with their stack traces are also exposed. For that, you will need to add the plugin in your graphene schema. @@ -63,6 +63,10 @@ the GraphQL request, like: sql { rawSql } + exceptions { + message + stack + } } } diff --git a/graphene_django/debug/exception/__init__.py b/graphene_django/debug/exception/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/graphene_django/debug/exception/formating.py b/graphene_django/debug/exception/formating.py new file mode 100644 index 0000000..ed7ebab --- /dev/null +++ b/graphene_django/debug/exception/formating.py @@ -0,0 +1,17 @@ +import traceback + +from django.utils.encoding import force_str + +from .types import DjangoDebugException + + +def wrap_exception(exception): + return DjangoDebugException( + message=force_str(exception), + exc_type=force_str(type(exception)), + stack="".join( + traceback.format_exception( + etype=type(exception), value=exception, tb=exception.__traceback__ + ) + ), + ) diff --git a/graphene_django/debug/exception/types.py b/graphene_django/debug/exception/types.py new file mode 100644 index 0000000..3484ccb --- /dev/null +++ b/graphene_django/debug/exception/types.py @@ -0,0 +1,10 @@ +from graphene import ObjectType, String + + +class DjangoDebugException(ObjectType): + class Meta: + description = "Represents a single exception raised." + + exc_type = String(required=True, description="The class of the exception") + message = String(required=True, description="The message of the exception") + stack = String(required=True, description="The stack trace") diff --git a/graphene_django/debug/middleware.py b/graphene_django/debug/middleware.py index 8621b55..804e7c8 100644 --- a/graphene_django/debug/middleware.py +++ b/graphene_django/debug/middleware.py @@ -3,6 +3,7 @@ from django.db import connections from promise import Promise from .sql.tracking import unwrap_cursor, wrap_cursor +from .exception.formating import wrap_exception from .types import DjangoDebug @@ -10,8 +11,8 @@ class DjangoDebugContext(object): def __init__(self): self.debug_promise = None self.promises = [] + self.object = DjangoDebug(sql=[], exceptions=[]) self.enable_instrumentation() - self.object = DjangoDebug(sql=[]) def get_debug_promise(self): if not self.debug_promise: @@ -19,6 +20,11 @@ class DjangoDebugContext(object): self.promises = [] return self.debug_promise.then(self.on_resolve_all_promises).get() + def on_resolve_error(self, value): + if hasattr(self, "object"): + self.object.exceptions.append(wrap_exception(value)) + return Promise.reject(value) + def on_resolve_all_promises(self, values): if self.promises: self.debug_promise = None @@ -57,6 +63,9 @@ class DjangoDebugMiddleware(object): ) if info.schema.get_type("DjangoDebug") == info.return_type: return context.django_debug.get_debug_promise() - promise = next(root, info, **args) + try: + promise = next(root, info, **args) + except Exception as e: + return context.django_debug.on_resolve_error(e) context.django_debug.add_promise(promise) return promise diff --git a/graphene_django/debug/tests/test_query.py b/graphene_django/debug/tests/test_query.py index d963b9c..eae94dc 100644 --- a/graphene_django/debug/tests/test_query.py +++ b/graphene_django/debug/tests/test_query.py @@ -272,3 +272,42 @@ def test_should_query_connectionfilter(graphene_settings, max_limit): assert "COUNT" in result.data["_debug"]["sql"][0]["rawSql"] query = str(Reporter.objects.all()[:1].query) assert result.data["_debug"]["sql"][1]["rawSql"] == query + + +def test_should_query_stack_trace(): + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + fields = "__all__" + + class Query(graphene.ObjectType): + reporter = graphene.Field(ReporterType) + debug = graphene.Field(DjangoDebug, name="_debug") + + def resolve_reporter(self, info, **args): + raise Exception("caught stack trace") + + query = """ + query ReporterQuery { + reporter { + lastName + } + _debug { + exceptions { + message + stack + } + } + } + """ + schema = graphene.Schema(query=Query) + result = schema.execute( + query, context_value=context(), middleware=[DjangoDebugMiddleware()] + ) + assert result.errors + assert len(result.data["_debug"]["exceptions"]) + debug_exception = result.data["_debug"]["exceptions"][0] + assert debug_exception["stack"].count("\n") > 1 + assert "test_query.py" in debug_exception["stack"] + assert debug_exception["message"] == "caught stack trace" diff --git a/graphene_django/debug/types.py b/graphene_django/debug/types.py index 1cd816d..a523b4f 100644 --- a/graphene_django/debug/types.py +++ b/graphene_django/debug/types.py @@ -1,6 +1,7 @@ from graphene import List, ObjectType from .sql.types import DjangoDebugSQL +from .exception.types import DjangoDebugException class DjangoDebug(ObjectType): @@ -8,3 +9,6 @@ class DjangoDebug(ObjectType): description = "Debugging information for the current query." sql = List(DjangoDebugSQL, description="Executed SQL queries for this API query.") + exceptions = List( + DjangoDebugException, description="Raise exceptions for this API query." + ) From fe66b48d38a01e6a3acd8f870a6b5bc4e49381fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebasti=C3=A1n=20Sastoque=20H?= Date: Tue, 2 Mar 2021 19:46:35 +0100 Subject: [PATCH 13/52] Fix main branch tests failing due to wrong instancing of Missing class (#1135) Co-authored-by: Sebastian Hernandez --- graphene_django/compat.py | 3 ++- graphene_django/filter/tests/test_array_field_exact_filter.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/graphene_django/compat.py b/graphene_django/compat.py index 537fd1d..1956786 100644 --- a/graphene_django/compat.py +++ b/graphene_django/compat.py @@ -1,5 +1,6 @@ class MissingType(object): - pass + def __init__(self, *args, **kwargs): + pass try: diff --git a/graphene_django/filter/tests/test_array_field_exact_filter.py b/graphene_django/filter/tests/test_array_field_exact_filter.py index b07abed..cd72868 100644 --- a/graphene_django/filter/tests/test_array_field_exact_filter.py +++ b/graphene_django/filter/tests/test_array_field_exact_filter.py @@ -81,6 +81,7 @@ def test_array_field_exact_empty_list(Query): ] +@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist") def test_array_field_filter_schema_type(Query): """ Check that the type in the filter is an array field like on the object type. From 212524fd8c69f7c8167f1a51e762c94105aa469c Mon Sep 17 00:00:00 2001 From: Tonye Jack Date: Thu, 18 Mar 2021 05:26:02 -0400 Subject: [PATCH 14/52] Cleaned up unused imports and variables. (#1146) * Cleaned up used imports and variables. * Optimized imports. * Fixed mixed imports. --- graphene_django/fields.py | 4 ++-- graphene_django/filter/filterset.py | 1 - graphene_django/filter/utils.py | 7 ++----- graphene_django/forms/mutation.py | 4 ---- graphene_django/forms/types.py | 2 -- graphene_django/types.py | 15 +++------------ 6 files changed, 7 insertions(+), 26 deletions(-) diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 4a85f57..e1972c7 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -156,8 +156,8 @@ class DjangoConnectionField(ConnectionField): # AssertionError after = min(get_offset_with_default(args.get("after"), -1) + 1, list_length) - if max_limit is not None and args.get("first", None) == None: - if args.get("last", None) != None: + if max_limit is not None and args.get("first", None) is None: + if args.get("last", None) is not None: after = list_length - args["last"] else: args["first"] = max_limit diff --git a/graphene_django/filter/filterset.py b/graphene_django/filter/filterset.py index 64453ea..b3333bf 100644 --- a/graphene_django/filter/filterset.py +++ b/graphene_django/filter/filterset.py @@ -1,7 +1,6 @@ import itertools from django.db import models -from django_filters import Filter, MultipleChoiceFilter from django_filters.filterset import BaseFilterSet, FilterSet from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index 9922cc8..ef18a86 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -1,12 +1,9 @@ import graphene - from django import forms +from django_filters.utils import get_model_field -from django_filters.utils import get_model_field, get_field_parts -from django_filters.filters import Filter, BaseCSVFilter - -from .filterset import custom_filterset_factory, setup_filterset from .filters import ArrayFilter, ListFilter, RangeFilter +from .filterset import custom_filterset_factory, setup_filterset from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField diff --git a/graphene_django/forms/mutation.py b/graphene_django/forms/mutation.py index cc7d647..e56c99c 100644 --- a/graphene_django/forms/mutation.py +++ b/graphene_django/forms/mutation.py @@ -14,10 +14,6 @@ from graphene.types.utils import yank_fields_from_attrs from graphene_django.constants import MUTATION_ERRORS_FLAG from graphene_django.registry import get_global_registry - -from django.core.exceptions import ValidationError -from django.db import connection - from ..types import ErrorType from .converter import convert_form_field diff --git a/graphene_django/forms/types.py b/graphene_django/forms/types.py index 5005040..74e275e 100644 --- a/graphene_django/forms/types.py +++ b/graphene_django/forms/types.py @@ -1,3 +1 @@ -import graphene - from ..types import ErrorType # noqa Import ErrorType for backwards compatability diff --git a/graphene_django/types.py b/graphene_django/types.py index 53c4d23..d272412 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -2,11 +2,8 @@ import warnings from collections import OrderedDict from typing import Type -from django.db.models import Model -from django.utils.functional import SimpleLazyObject - import graphene -from graphene import Field +from django.db.models import Model from graphene.relay import Connection, Node from graphene.types.objecttype import ObjectType, ObjectTypeOptions from graphene.types.utils import yank_fields_from_attrs @@ -21,7 +18,6 @@ from .utils import ( is_valid_django_model, ) - ALL_FIELDS = "__all__" @@ -108,12 +104,7 @@ def validate_fields(type_, model, fields, only_fields, exclude_fields): ( 'Excluding the custom field "{field_name}" on DjangoObjectType "{type_}" has no effect. ' 'Either remove the custom field or remove the field from the "exclude" list.' - ).format( - field_name=name, - app_label=model._meta.app_label, - object_name=model._meta.object_name, - type_=type_, - ) + ).format(field_name=name, type_=type_) ) else: if not hasattr(model, name): @@ -232,7 +223,7 @@ class DjangoObjectType(ObjectType): django_fields = yank_fields_from_attrs( construct_fields(model, registry, fields, exclude, convert_choices_to_enum), - _as=Field, + _as=graphene.Field, ) if use_connection is None and interfaces: From 573d38e13e6e5aa5becd8e0f4b7b4051f54f1e7f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 20 Mar 2021 22:38:38 -0700 Subject: [PATCH 15/52] Bump django from 3.0.7 to 3.1.6 in /examples/cookbook-plain (#1149) Bumps [django](https://github.com/django/django) from 3.0.7 to 3.1.6. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/3.0.7...3.1.6) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- 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 ae9ecc9..aaf687b 100644 --- a/examples/cookbook-plain/requirements.txt +++ b/examples/cookbook-plain/requirements.txt @@ -1,4 +1,4 @@ graphene>=2.1,<3 graphene-django>=2.1,<3 graphql-core>=2.1,<3 -django==3.0.7 +django==3.1.6 From 594ca6e25ef4f864f385b32350533215619f521c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 20 Mar 2021 22:38:50 -0700 Subject: [PATCH 16/52] Bump django from 3.0.7 to 3.1.6 in /examples/cookbook (#1150) Bumps [django](https://github.com/django/django) from 3.0.7 to 3.1.6. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/3.0.7...3.1.6) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- 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 7ae2d89..a06c402 100644 --- a/examples/cookbook/requirements.txt +++ b/examples/cookbook/requirements.txt @@ -1,5 +1,5 @@ graphene>=2.1,<3 graphene-django>=2.1,<3 graphql-core>=2.1,<3 -django==3.0.7 +django==3.1.6 django-filter>=2 From 3058118e8fc64a0a0853b67364381eccc7746f67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Wed, 24 Mar 2021 09:32:37 +0300 Subject: [PATCH 17/52] Tox & actions updates (#1143) * Update Django's main branch name * Add Python 3.9 to tox * Update base gh action versions * Add Django 3.2 to tests * Remove redundant Django 1.11 references * Update setup.py for new Django and Python versions --- .github/workflows/deploy.yml | 8 ++++---- .github/workflows/lint.yml | 8 ++++---- .github/workflows/tests.yml | 8 ++++---- docs/tutorial-relay.rst | 2 +- graphene_django/converter.py | 5 +---- graphene_django/settings.py | 5 +---- setup.py | 4 +++- tox.ini | 15 ++++++++------- 8 files changed, 26 insertions(+), 29 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1cd1011..07c0766 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,11 +10,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 - - name: Set up Python 3.8 - uses: actions/setup-python@v1 + - uses: actions/checkout@v2 + - name: Set up Python 3.9 + uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Build wheel and source tarball run: | pip install wheel diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 20cf7fb..559326c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,11 +7,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 - - name: Set up Python 3.8 - uses: actions/setup-python@v1 + - uses: actions/checkout@v2 + - name: Set up Python 3.9 + uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 90085c8..2dbf822 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,13 +8,13 @@ jobs: strategy: max-parallel: 4 matrix: - django: ["2.2", "3.0", "3.1"] - python-version: ["3.6", "3.7", "3.8"] + django: ["2.2", "3.0", "3.1", "3.2"] + python-version: ["3.6", "3.7", "3.8", "3.9"] steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst index 8494da1..acc4b0d 100644 --- a/docs/tutorial-relay.rst +++ b/docs/tutorial-relay.rst @@ -281,7 +281,7 @@ from the command line. $ python ./manage.py runserver Performing system checks... - Django version 1.11, using settings 'cookbook.settings' + Django version 3.1.7, using settings 'cookbook.settings' Starting development server at http://127.0.0.1:8000/ Quit the server with CONTROL-C. diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 6bbf534..da96161 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -238,10 +238,7 @@ def convert_onetoone_field_to_djangomodel(field, registry=None): if not _type: return - # We do this for a bug in Django 1.8, where null attr - # is not available in the OneToOneRel instance - null = getattr(field, "null", True) - return Field(_type, required=not null) + return Field(_type, required=not field.null) return Dynamic(dynamic_type) diff --git a/graphene_django/settings.py b/graphene_django/settings.py index c487123..467c6a3 100644 --- a/graphene_django/settings.py +++ b/graphene_django/settings.py @@ -16,10 +16,7 @@ from __future__ import unicode_literals from django.conf import settings from django.test.signals import setting_changed -try: - import importlib # Available in Python 3.1+ -except ImportError: - from django.utils import importlib # Will be removed in Django 1.9 +import importlib # Available in Python 3.1+ # Copied shamelessly from Django REST Framework diff --git a/setup.py b/setup.py index 4750671..fd403c0 100644 --- a/setup.py +++ b/setup.py @@ -49,11 +49,13 @@ setup( "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", "Programming Language :: Python :: Implementation :: PyPy", "Framework :: Django", - "Framework :: Django :: 1.11", "Framework :: Django :: 2.2", "Framework :: Django :: 3.0", + "Framework :: Django :: 3.1", + "Framework :: Django :: 3.2", ], keywords="api graphql protocol rest relay graphene", packages=find_packages(exclude=["tests", "examples", "examples.*"]), diff --git a/tox.ini b/tox.ini index 4fd5a1b..7128afe 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ [tox] envlist = - py{36,37,38}-django{22,30,31,master}, + py{36,37,38,39}-django{22,30,31,32,main}, black,flake8 [gh-actions] @@ -8,13 +8,15 @@ python = 3.6: py36 3.7: py37 3.8: py38 + 3.9: py39 [gh-actions:env] DJANGO = 2.2: django22 3.0: django30 3.1: django31 - master: djangomaster + 3.2: django32 + main: djangomain [testenv] passenv = * @@ -24,24 +26,23 @@ setenv = deps = -e.[test] psycopg2-binary - django111: Django>=1.11,<2.0 - django111: djangorestframework<3.12 django20: Django>=2.0,<2.1 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 + django32: Django>=3.2a1,<3.3 + djangomain: https://github.com/django/django/archive/main.zip commands = {posargs:py.test --cov=graphene_django graphene_django examples} [testenv:black] -basepython = python3.8 +basepython = python3.9 deps = -e.[dev] commands = black --exclude "/migrations/" graphene_django examples setup.py --check [testenv:flake8] -basepython = python3.8 +basepython = python3.9 deps = -e.[dev] commands = flake8 graphene_django examples setup.py From 3cf940d0c899be0c7703b3e85c2ed2097fe32ab9 Mon Sep 17 00:00:00 2001 From: Kuba Misiorny Date: Wed, 31 Mar 2021 19:31:20 +0200 Subject: [PATCH 18/52] Add ability to pass `execution_context_class` to `GraphQLView.as_view()` (#1109) * Add ability to pass `execution_context_class` to `GraphQLView.as_view()` Currently when passing `execution_context_class` like this: ``` GraphQLView.as_view(execution_context_class=CustomContext) ``` you get the following error from `View.as_view()` ``` TypeError: GraphQLView() received an invalid keyword 'execution_context_class'. as_view only accepts arguments that are already attributes of the class. ``` this PR fixes the `hasattr` check in `.as_view`. Fixes: #1072 * make black happy removed whitespace --- graphene_django/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/graphene_django/views.py b/graphene_django/views.py index 8341fc4..2e867a0 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -84,6 +84,7 @@ class GraphQLView(View): pretty = False batch = False subscription_path = None + execution_context_class = None def __init__( self, From 80ea51fc3b433430efc000cbd6b126215ab53905 Mon Sep 17 00:00:00 2001 From: Thomas Leonard <64223923+tcleonard@users.noreply.github.com> Date: Wed, 31 Mar 2021 19:31:45 +0200 Subject: [PATCH 19/52] Add typed filters (v3) (#1148) * feat: add TypedFilter which allow to explicitly give a filter input GraphQL type * Fix doc typo Co-authored-by: Thomas Leonard --- docs/filtering.rst | 42 ++++- graphene_django/filter/__init__.py | 2 + graphene_django/filter/filters.py | 101 ----------- graphene_django/filter/filters/__init__.py | 25 +++ .../filter/filters/array_filter.py | 27 +++ .../filter/filters/global_id_filter.py | 28 ++++ graphene_django/filter/filters/list_filter.py | 26 +++ .../filter/filters/range_filter.py | 24 +++ .../filter/filters/typed_filter.py | 27 +++ .../filter/tests/test_typed_filter.py | 157 ++++++++++++++++++ graphene_django/filter/utils.py | 100 +++++------ 11 files changed, 409 insertions(+), 150 deletions(-) delete mode 100644 graphene_django/filter/filters.py create mode 100644 graphene_django/filter/filters/__init__.py create mode 100644 graphene_django/filter/filters/array_filter.py create mode 100644 graphene_django/filter/filters/global_id_filter.py create mode 100644 graphene_django/filter/filters/list_filter.py create mode 100644 graphene_django/filter/filters/range_filter.py create mode 100644 graphene_django/filter/filters/typed_filter.py create mode 100644 graphene_django/filter/tests/test_typed_filter.py diff --git a/docs/filtering.rst b/docs/filtering.rst index beb5e5b..934bad6 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -15,7 +15,7 @@ You will need to install it manually, which can be done as follows: # You'll need to install django-filter pip install django-filter>=2 - + After installing ``django-filter`` you'll need to add the application in the ``settings.py`` file: .. code:: python @@ -290,6 +290,7 @@ Graphene provides an easy to implement filters on `ArrayField` as they are not n class Meta: model = Event interfaces = (Node,) + fields = "__all__" filterset_class = EventFilterSet with this set up, you can now filter events by tags: @@ -301,3 +302,42 @@ with this set up, you can now filter events by tags: name } } + + +`TypedFilter` +------------- + +Sometimes the automatic detection of the filter input type is not satisfactory for what you are trying to achieve. +You can then explicitly specify the input type you want for your filter by using a `TypedFilter`: + +.. code:: python + + from django.db import models + from django_filters import FilterSet, OrderingFilter + import graphene + from graphene_django.filter import TypedFilter + + class Event(models.Model): + name = models.CharField(max_length=50) + + class EventFilterSet(FilterSet): + class Meta: + model = Event + fields = { + "name": ["exact", "contains"], + } + + only_first = TypedFilter(input_type=graphene.Boolean, method="only_first_filter") + + def only_first_filter(self, queryset, _name, value): + if value: + return queryset[:1] + else: + return queryset + + class EventType(DjangoObjectType): + class Meta: + model = Event + interfaces = (Node,) + fields = "__all__" + filterset_class = EventFilterSet diff --git a/graphene_django/filter/__init__.py b/graphene_django/filter/__init__.py index 94570c9..f02fc6b 100644 --- a/graphene_django/filter/__init__.py +++ b/graphene_django/filter/__init__.py @@ -15,6 +15,7 @@ else: GlobalIDMultipleChoiceFilter, ListFilter, RangeFilter, + TypedFilter, ) __all__ = [ @@ -24,4 +25,5 @@ else: "ArrayFilter", "ListFilter", "RangeFilter", + "TypedFilter", ] diff --git a/graphene_django/filter/filters.py b/graphene_django/filter/filters.py deleted file mode 100644 index e23626a..0000000 --- a/graphene_django/filter/filters.py +++ /dev/null @@ -1,101 +0,0 @@ -from django.core.exceptions import ValidationError -from django.forms import Field - -from django_filters import Filter, MultipleChoiceFilter -from django_filters.constants import EMPTY_VALUES - -from graphql_relay.node.node import from_global_id - -from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField - - -class GlobalIDFilter(Filter): - """ - Filter for Relay global ID. - """ - - field_class = GlobalIDFormField - - def filter(self, qs, value): - """ Convert the filter value to a primary key before filtering """ - _id = None - if value is not None: - _, _id = from_global_id(value) - return super(GlobalIDFilter, self).filter(qs, _id) - - -class GlobalIDMultipleChoiceFilter(MultipleChoiceFilter): - field_class = GlobalIDMultipleChoiceField - - def filter(self, qs, value): - gids = [from_global_id(v)[1] for v in value] - return super(GlobalIDMultipleChoiceFilter, self).filter(qs, gids) - - -class ListFilter(Filter): - """ - Filter that takes a list of value as input. - It is for example used for `__in` filters. - """ - - def filter(self, qs, value): - """ - Override the default filter class to check first whether the list is - empty or not. - This needs to be done as in this case we expect to get an empty output - (if not an exclude filter) but django_filter consider an empty list - to be an empty input value (see `EMPTY_VALUES`) meaning that - the filter does not need to be applied (hence returning the original - queryset). - """ - if value is not None and len(value) == 0: - if self.exclude: - return qs - else: - return qs.none() - else: - return super().filter(qs, value) - - -def validate_range(value): - """ - Validator for range filter input: the list of value must be of length 2. - Note that validators are only run if the value is not empty. - """ - if len(value) != 2: - raise ValidationError( - "Invalid range specified: it needs to contain 2 values.", code="invalid" - ) - - -class RangeField(Field): - default_validators = [validate_range] - empty_values = [None] - - -class RangeFilter(Filter): - field_class = RangeField - - -class ArrayFilter(Filter): - """ - Filter made for PostgreSQL ArrayField. - """ - - def filter(self, qs, value): - """ - Override the default filter class to check first whether the list is - empty or not. - This needs to be done as in this case we expect to get the filter applied with - an empty list since it's a valid value but django_filter consider an empty list - to be an empty input value (see `EMPTY_VALUES`) meaning that - the filter does not need to be applied (hence returning the original - queryset). - """ - if value in EMPTY_VALUES and value != []: - return qs - if self.distinct: - qs = qs.distinct() - lookup = "%s__%s" % (self.field_name, self.lookup_expr) - qs = self.get_method(qs)(**{lookup: value}) - return qs diff --git a/graphene_django/filter/filters/__init__.py b/graphene_django/filter/filters/__init__.py new file mode 100644 index 0000000..fcf75af --- /dev/null +++ b/graphene_django/filter/filters/__init__.py @@ -0,0 +1,25 @@ +import warnings +from ...utils import DJANGO_FILTER_INSTALLED + +if not DJANGO_FILTER_INSTALLED: + warnings.warn( + "Use of django filtering requires the django-filter package " + "be installed. You can do so using `pip install django-filter`", + ImportWarning, + ) +else: + from .array_filter import ArrayFilter + from .global_id_filter import GlobalIDFilter, GlobalIDMultipleChoiceFilter + from .list_filter import ListFilter + from .range_filter import RangeFilter + from .typed_filter import TypedFilter + + __all__ = [ + "DjangoFilterConnectionField", + "GlobalIDFilter", + "GlobalIDMultipleChoiceFilter", + "ArrayFilter", + "ListFilter", + "RangeFilter", + "TypedFilter", + ] diff --git a/graphene_django/filter/filters/array_filter.py b/graphene_django/filter/filters/array_filter.py new file mode 100644 index 0000000..e886cff --- /dev/null +++ b/graphene_django/filter/filters/array_filter.py @@ -0,0 +1,27 @@ +from django_filters.constants import EMPTY_VALUES + +from .typed_filter import TypedFilter + + +class ArrayFilter(TypedFilter): + """ + Filter made for PostgreSQL ArrayField. + """ + + def filter(self, qs, value): + """ + Override the default filter class to check first whether the list is + empty or not. + This needs to be done as in this case we expect to get the filter applied with + an empty list since it's a valid value but django_filter consider an empty list + to be an empty input value (see `EMPTY_VALUES`) meaning that + the filter does not need to be applied (hence returning the original + queryset). + """ + if value in EMPTY_VALUES and value != []: + return qs + if self.distinct: + qs = qs.distinct() + lookup = "%s__%s" % (self.field_name, self.lookup_expr) + qs = self.get_method(qs)(**{lookup: value}) + return qs diff --git a/graphene_django/filter/filters/global_id_filter.py b/graphene_django/filter/filters/global_id_filter.py new file mode 100644 index 0000000..a612a8a --- /dev/null +++ b/graphene_django/filter/filters/global_id_filter.py @@ -0,0 +1,28 @@ +from django_filters import Filter, MultipleChoiceFilter + +from graphql_relay.node.node import from_global_id + +from ...forms import GlobalIDFormField, GlobalIDMultipleChoiceField + + +class GlobalIDFilter(Filter): + """ + Filter for Relay global ID. + """ + + field_class = GlobalIDFormField + + def filter(self, qs, value): + """ Convert the filter value to a primary key before filtering """ + _id = None + if value is not None: + _, _id = from_global_id(value) + return super(GlobalIDFilter, self).filter(qs, _id) + + +class GlobalIDMultipleChoiceFilter(MultipleChoiceFilter): + field_class = GlobalIDMultipleChoiceField + + def filter(self, qs, value): + gids = [from_global_id(v)[1] for v in value] + return super(GlobalIDMultipleChoiceFilter, self).filter(qs, gids) diff --git a/graphene_django/filter/filters/list_filter.py b/graphene_django/filter/filters/list_filter.py new file mode 100644 index 0000000..9689be3 --- /dev/null +++ b/graphene_django/filter/filters/list_filter.py @@ -0,0 +1,26 @@ +from .typed_filter import TypedFilter + + +class ListFilter(TypedFilter): + """ + Filter that takes a list of value as input. + It is for example used for `__in` filters. + """ + + def filter(self, qs, value): + """ + Override the default filter class to check first whether the list is + empty or not. + This needs to be done as in this case we expect to get an empty output + (if not an exclude filter) but django_filter consider an empty list + to be an empty input value (see `EMPTY_VALUES`) meaning that + the filter does not need to be applied (hence returning the original + queryset). + """ + if value is not None and len(value) == 0: + if self.exclude: + return qs + else: + return qs.none() + else: + return super(ListFilter, self).filter(qs, value) diff --git a/graphene_django/filter/filters/range_filter.py b/graphene_django/filter/filters/range_filter.py new file mode 100644 index 0000000..c2faddb --- /dev/null +++ b/graphene_django/filter/filters/range_filter.py @@ -0,0 +1,24 @@ +from django.core.exceptions import ValidationError +from django.forms import Field + +from .typed_filter import TypedFilter + + +def validate_range(value): + """ + Validator for range filter input: the list of value must be of length 2. + Note that validators are only run if the value is not empty. + """ + if len(value) != 2: + raise ValidationError( + "Invalid range specified: it needs to contain 2 values.", code="invalid" + ) + + +class RangeField(Field): + default_validators = [validate_range] + empty_values = [None] + + +class RangeFilter(TypedFilter): + field_class = RangeField diff --git a/graphene_django/filter/filters/typed_filter.py b/graphene_django/filter/filters/typed_filter.py new file mode 100644 index 0000000..2c813e4 --- /dev/null +++ b/graphene_django/filter/filters/typed_filter.py @@ -0,0 +1,27 @@ +from django_filters import Filter + +from graphene.types.utils import get_type + + +class TypedFilter(Filter): + """ + Filter class for which the input GraphQL type can explicitly be provided. + If it is not provided, when building the schema, it will try to guess + it from the field. + """ + + def __init__(self, input_type=None, *args, **kwargs): + self._input_type = input_type + super(TypedFilter, self).__init__(*args, **kwargs) + + @property + def input_type(self): + input_type = get_type(self._input_type) + if input_type is not None: + if not callable(getattr(input_type, "get_type", None)): + raise ValueError( + "Wrong `input_type` for {}: it only accepts graphene types, got {}".format( + self.__class__.__name__, input_type + ) + ) + return input_type diff --git a/graphene_django/filter/tests/test_typed_filter.py b/graphene_django/filter/tests/test_typed_filter.py new file mode 100644 index 0000000..b903b59 --- /dev/null +++ b/graphene_django/filter/tests/test_typed_filter.py @@ -0,0 +1,157 @@ +import pytest + +from django_filters import FilterSet + +import graphene +from graphene.relay import Node + +from graphene_django import DjangoObjectType +from graphene_django.tests.models import Article, Reporter +from graphene_django.utils import DJANGO_FILTER_INSTALLED + +pytestmark = [] + +if DJANGO_FILTER_INSTALLED: + from graphene_django.filter import ( + DjangoFilterConnectionField, + TypedFilter, + ListFilter, + ) +else: + pytestmark.append( + pytest.mark.skipif( + True, reason="django_filters not installed or not compatible" + ) + ) + + +@pytest.fixture +def schema(): + class ArticleFilterSet(FilterSet): + class Meta: + model = Article + fields = { + "lang": ["exact", "in"], + } + + lang__contains = TypedFilter( + field_name="lang", lookup_expr="icontains", input_type=graphene.String + ) + lang__in_str = ListFilter( + field_name="lang", + lookup_expr="in", + input_type=graphene.List(graphene.String), + ) + first_n = TypedFilter(input_type=graphene.Int, method="first_n_filter") + only_first = TypedFilter( + input_type=graphene.Boolean, method="only_first_filter" + ) + + def first_n_filter(self, queryset, _name, value): + return queryset[:value] + + def only_first_filter(self, queryset, _name, value): + if value: + return queryset[:1] + else: + return queryset + + class ArticleType(DjangoObjectType): + class Meta: + model = Article + interfaces = (Node,) + fields = "__all__" + filterset_class = ArticleFilterSet + + class Query(graphene.ObjectType): + articles = DjangoFilterConnectionField(ArticleType) + + schema = graphene.Schema(query=Query) + return schema + + +def test_typed_filter_schema(schema): + """ + Check that the type provided in the filter is reflected in the schema. + """ + + schema_str = str(schema) + + filters = { + "offset": "Int", + "before": "String", + "after": "String", + "first": "Int", + "last": "Int", + "lang": "TestsArticleLangChoices", + "lang_In": "[TestsArticleLangChoices]", + "lang_Contains": "String", + "lang_InStr": "[String]", + "firstN": "Int", + "onlyFirst": "Boolean", + } + + all_articles_filters = ( + schema_str.split(" articles(")[1] + .split("): ArticleTypeConnection\n")[0] + .split(", ") + ) + + for filter_field, gql_type in filters.items(): + assert "{}: {} = null".format(filter_field, gql_type) in all_articles_filters + + +def test_typed_filters_work(schema): + reporter = Reporter.objects.create(first_name="John", last_name="Doe", email="") + Article.objects.create( + headline="A", reporter=reporter, editor=reporter, lang="es", + ) + Article.objects.create( + headline="B", reporter=reporter, editor=reporter, lang="es", + ) + Article.objects.create( + headline="C", reporter=reporter, editor=reporter, lang="en", + ) + + query = "query { articles (lang_In: [ES]) { edges { node { headline } } } }" + + result = schema.execute(query) + assert not result.errors + assert result.data["articles"]["edges"] == [ + {"node": {"headline": "A"}}, + {"node": {"headline": "B"}}, + ] + + query = 'query { articles (lang_InStr: ["es"]) { edges { node { headline } } } }' + + result = schema.execute(query) + assert not result.errors + assert result.data["articles"]["edges"] == [ + {"node": {"headline": "A"}}, + {"node": {"headline": "B"}}, + ] + + query = 'query { articles (lang_Contains: "n") { edges { node { headline } } } }' + + result = schema.execute(query) + assert not result.errors + assert result.data["articles"]["edges"] == [ + {"node": {"headline": "C"}}, + ] + + query = "query { articles (firstN: 2) { edges { node { headline } } } }" + + result = schema.execute(query) + assert not result.errors + assert result.data["articles"]["edges"] == [ + {"node": {"headline": "A"}}, + {"node": {"headline": "B"}}, + ] + + query = "query { articles (onlyFirst: true) { edges { node { headline } } } }" + + result = schema.execute(query) + assert not result.errors + assert result.data["articles"]["edges"] == [ + {"node": {"headline": "A"}}, + ] diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index ef18a86..cd05a87 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -1,8 +1,8 @@ import graphene from django import forms -from django_filters.utils import get_model_field - -from .filters import ArrayFilter, ListFilter, RangeFilter +from django_filters.utils import get_model_field, get_field_parts +from django_filters.filters import Filter, BaseCSVFilter +from .filters import ArrayFilter, ListFilter, RangeFilter, TypedFilter from .filterset import custom_filterset_factory, setup_filterset from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField @@ -39,58 +39,62 @@ def get_filtering_args_from_filterset(filterset_class, type): form_field = None if ( - name not in filterset_class.declared_filters - or isinstance(filter_field, ListFilter) - or isinstance(filter_field, RangeFilter) - or isinstance(filter_field, ArrayFilter) + isinstance(filter_field, TypedFilter) + and filter_field.input_type is not None ): - # Get the filter field for filters that are no explicitly declared. - if filter_type == "isnull": - field = graphene.Boolean(required=required) - else: - model_field = get_model_field(model, filter_field.field_name) + # First check if the filter input type has been explicitely given + field_type = filter_field.input_type + else: + if name not in filterset_class.declared_filters or isinstance( + filter_field, TypedFilter + ): + # Get the filter field for filters that are no explicitly declared. + if filter_type == "isnull": + field = graphene.Boolean(required=required) + else: + model_field = get_model_field(model, filter_field.field_name) - # Get the form field either from: - # 1. the formfield corresponding to the model field - # 2. the field defined on filter - if hasattr(model_field, "formfield"): - form_field = model_field.formfield(required=required) - if not form_field: - form_field = filter_field.field + # Get the form field either from: + # 1. the formfield corresponding to the model field + # 2. the field defined on filter + if hasattr(model_field, "formfield"): + form_field = model_field.formfield(required=required) + if not form_field: + form_field = filter_field.field - # First try to get the matching field type from the GraphQL DjangoObjectType - if model_field: - if ( - isinstance(form_field, forms.ModelChoiceField) - or isinstance(form_field, forms.ModelMultipleChoiceField) - or isinstance(form_field, GlobalIDMultipleChoiceField) - or isinstance(form_field, GlobalIDFormField) - ): - # Foreign key have dynamic types and filtering on a foreign key actually means filtering on its ID. - field_type = get_field_type( - registry, model_field.related_model, "id" - ) - else: - field_type = get_field_type( - registry, model_field.model, model_field.name - ) + # First try to get the matching field type from the GraphQL DjangoObjectType + if model_field: + if ( + isinstance(form_field, forms.ModelChoiceField) + or isinstance(form_field, forms.ModelMultipleChoiceField) + or isinstance(form_field, GlobalIDMultipleChoiceField) + or isinstance(form_field, GlobalIDFormField) + ): + # Foreign key have dynamic types and filtering on a foreign key actually means filtering on its ID. + field_type = get_field_type( + registry, model_field.related_model, "id" + ) + else: + field_type = get_field_type( + registry, model_field.model, model_field.name + ) - if not field_type: - # Fallback on converting the form field either because: - # - it's an explicitly declared filters - # - we did not manage to get the type from the model type - form_field = form_field or filter_field.field - field_type = convert_form_field(form_field) + if not field_type: + # Fallback on converting the form field either because: + # - it's an explicitly declared filters + # - we did not manage to get the type from the model type + form_field = form_field or filter_field.field + field_type = convert_form_field(form_field).get_type() - if isinstance(filter_field, ListFilter) or isinstance( - filter_field, RangeFilter - ): - # Replace InFilter/RangeFilter filters (`in`, `range`) argument type to be a list of - # the same type as the field. See comments in `replace_csv_filters` method for more details. - field_type = graphene.List(field_type.get_type()) + if isinstance(filter_field, ListFilter) or isinstance( + filter_field, RangeFilter + ): + # Replace InFilter/RangeFilter filters (`in`, `range`) argument type to be a list of + # the same type as the field. See comments in `replace_csv_filters` method for more details. + field_type = graphene.List(field_type) args[name] = graphene.Argument( - field_type.get_type(), description=filter_field.label, required=required, + field_type, description=filter_field.label, required=required, ) return args From 762eaabd04bd6142a97c12ef524a71cef053d031 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 10 Apr 2021 19:28:12 -0700 Subject: [PATCH 20/52] Bump django from 3.1.6 to 3.1.8 in /examples/cookbook (#1156) Bumps [django](https://github.com/django/django) from 3.1.6 to 3.1.8. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/3.1.6...3.1.8) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- 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 a06c402..f6d03ff 100644 --- a/examples/cookbook/requirements.txt +++ b/examples/cookbook/requirements.txt @@ -1,5 +1,5 @@ graphene>=2.1,<3 graphene-django>=2.1,<3 graphql-core>=2.1,<3 -django==3.1.6 +django==3.1.8 django-filter>=2 From 26a851a5235b197835eb993f6436d0c648efab7d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 10 Apr 2021 19:28:41 -0700 Subject: [PATCH 21/52] Bump django from 3.1.6 to 3.1.8 in /examples/cookbook-plain (#1157) Bumps [django](https://github.com/django/django) from 3.1.6 to 3.1.8. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/3.1.6...3.1.8) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- 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 aaf687b..9fc1a3a 100644 --- a/examples/cookbook-plain/requirements.txt +++ b/examples/cookbook-plain/requirements.txt @@ -1,4 +1,4 @@ graphene>=2.1,<3 graphene-django>=2.1,<3 graphql-core>=2.1,<3 -django==3.1.6 +django==3.1.8 From 608af578d4fc446b4ae452f5d41595c42ba389f4 Mon Sep 17 00:00:00 2001 From: Eero Ruohola Date: Sun, 11 Apr 2021 06:30:15 +0300 Subject: [PATCH 22/52] Fix broken form.save() call in DjangoFormMutation.perform_mutate (#1155) Django's plain (non-model) forms don't have the `save` method, so calling this would just result in an `AttributeError` before this change. Resolves #1152 --- graphene_django/forms/mutation.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/graphene_django/forms/mutation.py b/graphene_django/forms/mutation.py index e56c99c..5a3d8e7 100644 --- a/graphene_django/forms/mutation.py +++ b/graphene_django/forms/mutation.py @@ -101,7 +101,10 @@ class DjangoFormMutation(BaseDjangoFormMutation): @classmethod def perform_mutate(cls, form, info): - form.save() + if hasattr(form, "save"): + # `save` method won't exist on plain Django forms, but this mutation can + # in theory be used with `ModelForm`s as well and we do want to save them. + form.save() return cls(errors=[], **form.cleaned_data) From 623d0f219ebeaf2b11de4d7f79d84da8508197c8 Mon Sep 17 00:00:00 2001 From: Rainshaw Date: Wed, 21 Apr 2021 14:05:46 +0800 Subject: [PATCH 23/52] update js version (#1188) --- graphene_django/views.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/graphene_django/views.py b/graphene_django/views.py index 2e867a0..c23b020 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -58,23 +58,23 @@ class GraphQLView(View): graphiql_template = "graphene/graphiql.html" # Polyfill for window.fetch. - whatwg_fetch_version = "3.2.0" - whatwg_fetch_sri = "sha256-l6HCB9TT2v89oWbDdo2Z3j+PSVypKNLA/nqfzSbM8mo=" + whatwg_fetch_version = "3.6.2" + whatwg_fetch_sri = "sha256-+pQdxwAcHJdQ3e/9S4RK6g8ZkwdMgFQuHvLuN5uyk5c=" # React and ReactDOM. - react_version = "16.13.1" - react_sri = "sha256-yUhvEmYVhZ/GGshIQKArLvySDSh6cdmdcIx0spR3UP4=" - react_dom_sri = "sha256-vFt3l+illeNlwThbDUdoPTqF81M8WNSZZZt3HEjsbSU=" + react_version = "17.0.2" + react_sri = "sha256-Ipu/TQ50iCCVZBUsZyNJfxrDk0E2yhaEIz0vqI+kFG8=" + react_dom_sri = "sha256-nbMykgB6tsOFJ7OdVmPpdqMFVk4ZsqWocT6issAPUF0=" # The GraphiQL React app. - graphiql_version = "1.0.3" - graphiql_sri = "sha256-VR4buIDY9ZXSyCNFHFNik6uSe0MhigCzgN4u7moCOTk=" - graphiql_css_sri = "sha256-LwqxjyZgqXDYbpxQJ5zLQeNcf7WVNSJ+r8yp2rnWE/E=" + graphiql_version = "1.4.1" # "1.0.3" + graphiql_sri = "sha256-JUMkXBQWZMfJ7fGEsTXalxVA10lzKOS9loXdLjwZKi4=" # "sha256-VR4buIDY9ZXSyCNFHFNik6uSe0MhigCzgN4u7moCOTk=" + graphiql_css_sri = "sha256-Md3vdR7PDzWyo/aGfsFVF4tvS5/eAUWuIsg9QHUusCY=" # "sha256-LwqxjyZgqXDYbpxQJ5zLQeNcf7WVNSJ+r8yp2rnWE/E=" # The websocket transport library for subscriptions. - subscriptions_transport_ws_version = "0.9.17" + subscriptions_transport_ws_version = "0.9.18" subscriptions_transport_ws_sri = ( - "sha256-kCDzver8iRaIQ/SVlfrIwxaBQ/avXf9GQFJRLlErBnk=" + "sha256-i0hAXd4PdJ/cHX3/8tIy/Q/qKiWr5WSTxMFuL9tACkw=" ) schema = None From e7f7d8da07ba1020f9916153f17e97b0ec037712 Mon Sep 17 00:00:00 2001 From: Paul Bailey Date: Fri, 11 Jun 2021 15:41:02 -0500 Subject: [PATCH 24/52] Add missing auto fields (#1212) * add missing auto fields * add missing auto fields * skip small auto field sometimes * make small auto optional * make small auto optional --- graphene_django/converter.py | 8 ++++++++ graphene_django/tests/test_converter.py | 9 +++++++++ 2 files changed, 17 insertions(+) diff --git a/graphene_django/converter.py b/graphene_django/converter.py index da96161..c243e82 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -166,11 +166,19 @@ def convert_field_to_string(field, registry=None): ) +@convert_django_field.register(models.BigAutoField) @convert_django_field.register(models.AutoField) def convert_field_to_id(field, registry=None): return ID(description=get_django_field_description(field), required=not field.null) +if hasattr(models, "SmallAutoField"): + + @convert_django_field.register(models.SmallAutoField) + def convert_field_small_to_id(field, registry=None): + return convert_field_to_id(field, registry) + + @convert_django_field.register(models.UUIDField) def convert_field_to_uuid(field, registry=None): return UUID( diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index fe84e73..afd744f 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -111,6 +111,15 @@ def test_should_auto_convert_id(): assert_conversion(models.AutoField, graphene.ID, primary_key=True) +def test_should_big_auto_convert_id(): + assert_conversion(models.BigAutoField, graphene.ID, primary_key=True) + + +def test_should_small_auto_convert_id(): + if hasattr(models, "SmallAutoField"): + assert_conversion(models.SmallAutoField, graphene.ID, primary_key=True) + + def test_should_uuid_convert_id(): assert_conversion(models.UUIDField, graphene.UUID) From 1e4b03b9756baebbfcf7e9e3a25932a4e613570b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Dec 2021 12:49:16 +0300 Subject: [PATCH 25/52] Bump django from 3.1.8 to 3.1.14 in /examples/cookbook-plain (#1282) Bumps [django](https://github.com/django/django) from 3.1.8 to 3.1.14. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/3.1.8...3.1.14) --- updated-dependencies: - dependency-name: django dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- 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 9fc1a3a..85a8963 100644 --- a/examples/cookbook-plain/requirements.txt +++ b/examples/cookbook-plain/requirements.txt @@ -1,4 +1,4 @@ graphene>=2.1,<3 graphene-django>=2.1,<3 graphql-core>=2.1,<3 -django==3.1.8 +django==3.1.14 From ef9d67302ef85f1a5d3047a44cf65a2173c3ca2e Mon Sep 17 00:00:00 2001 From: Chouaib Lammas <54365321+chlammas@users.noreply.github.com> Date: Fri, 10 Dec 2021 09:51:10 +0000 Subject: [PATCH 26/52] Fix ingredient model (#1258) Add the required positional argument: 'on_delete' --- docs/tutorial-relay.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst index acc4b0d..3de9022 100644 --- a/docs/tutorial-relay.rst +++ b/docs/tutorial-relay.rst @@ -70,7 +70,7 @@ Let's get started with these models: class Ingredient(models.Model): name = models.CharField(max_length=100) notes = models.TextField() - category = models.ForeignKey(Category, related_name='ingredients') + category = models.ForeignKey(Category, related_name='ingredients', on_delete=models.CASCADE) def __str__(self): return self.name From 32667b5407a81ec58a0c22936f9ef234be47e20c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Dec 2021 12:58:03 +0300 Subject: [PATCH 27/52] Bump django from 3.1.8 to 3.1.14 in /examples/cookbook (#1283) Bumps [django](https://github.com/django/django) from 3.1.8 to 3.1.14. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/3.1.8...3.1.14) --- updated-dependencies: - dependency-name: django dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- 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 f6d03ff..a5b0b96 100644 --- a/examples/cookbook/requirements.txt +++ b/examples/cookbook/requirements.txt @@ -1,5 +1,5 @@ graphene>=2.1,<3 graphene-django>=2.1,<3 graphql-core>=2.1,<3 -django==3.1.8 +django==3.1.14 django-filter>=2 From 5d5d7f18154adc0d89e2c6baebd47c4e5d7d9fbf Mon Sep 17 00:00:00 2001 From: Tim Schilling Date: Fri, 7 Jan 2022 14:26:07 -0600 Subject: [PATCH 28/52] Django v4, python 3.10 support for graphene-django v3 (#1281) Co-authored-by: Yair Silbermintz --- .github/workflows/tests.yml | 8 +++++++- README.md | 2 +- docs/authorization.rst | 2 +- graphene_django/debug/exception/formating.py | 2 +- .../management/commands/graphql_schema.py | 2 +- graphene_django/tests/urls.py | 6 +++--- graphene_django/tests/urls_inherited.py | 4 ++-- graphene_django/tests/urls_pretty.py | 4 ++-- graphene_django/utils/testing.py | 2 +- tox.ini | 13 ++++++++----- 10 files changed, 27 insertions(+), 18 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2dbf822..c63742a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -10,7 +10,13 @@ jobs: matrix: django: ["2.2", "3.0", "3.1", "3.2"] python-version: ["3.6", "3.7", "3.8", "3.9"] - + include: + - django: "3.2" + python-version: "3.10" + - django: "4.0" + python-version: "3.10" + - django: "main" + python-version: "3.10" steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} diff --git a/README.md b/README.md index 5045e78..6f06ccc 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ from graphene_django.views import GraphQLView urlpatterns = [ # ... - path('graphql', GraphQLView.as_view(graphiql=True)), + path('graphql/', GraphQLView.as_view(graphiql=True)), ] ``` diff --git a/docs/authorization.rst b/docs/authorization.rst index 39305f6..bc88cda 100644 --- a/docs/authorization.rst +++ b/docs/authorization.rst @@ -198,7 +198,7 @@ For Django 2.2 and above: urlpatterns = [ # some other urls - path('graphql', PrivateGraphQLView.as_view(graphiql=True, schema=schema)), + path('graphql/', PrivateGraphQLView.as_view(graphiql=True, schema=schema)), ] .. _LoginRequiredMixin: https://docs.djangoproject.com/en/dev/topics/auth/default/#the-loginrequired-mixin diff --git a/graphene_django/debug/exception/formating.py b/graphene_django/debug/exception/formating.py index ed7ebab..0d477b3 100644 --- a/graphene_django/debug/exception/formating.py +++ b/graphene_django/debug/exception/formating.py @@ -11,7 +11,7 @@ def wrap_exception(exception): exc_type=force_str(type(exception)), stack="".join( traceback.format_exception( - etype=type(exception), value=exception, tb=exception.__traceback__ + exception, value=exception, tb=exception.__traceback__ ) ), ) diff --git a/graphene_django/management/commands/graphql_schema.py b/graphene_django/management/commands/graphql_schema.py index 565f5d8..4165430 100644 --- a/graphene_django/management/commands/graphql_schema.py +++ b/graphene_django/management/commands/graphql_schema.py @@ -48,7 +48,7 @@ class CommandArguments(BaseCommand): class Command(CommandArguments): help = "Dump Graphene schema as a JSON or GraphQL file" can_import_settings = True - requires_system_checks = False + requires_system_checks = [] def save_json_file(self, out, schema_dict, indent): with open(out, "w") as outfile: diff --git a/graphene_django/tests/urls.py b/graphene_django/tests/urls.py index 66b3fc4..3702ce5 100644 --- a/graphene_django/tests/urls.py +++ b/graphene_django/tests/urls.py @@ -1,8 +1,8 @@ -from django.conf.urls import url +from django.urls import path from ..views import GraphQLView urlpatterns = [ - url(r"^graphql/batch", GraphQLView.as_view(batch=True)), - url(r"^graphql", GraphQLView.as_view(graphiql=True)), + path("graphql/batch", GraphQLView.as_view(batch=True)), + path("graphql", GraphQLView.as_view(graphiql=True)), ] diff --git a/graphene_django/tests/urls_inherited.py b/graphene_django/tests/urls_inherited.py index 6fa8019..1e65da0 100644 --- a/graphene_django/tests/urls_inherited.py +++ b/graphene_django/tests/urls_inherited.py @@ -1,4 +1,4 @@ -from django.conf.urls import url +from django.urls import path from ..views import GraphQLView from .schema_view import schema @@ -10,4 +10,4 @@ class CustomGraphQLView(GraphQLView): pretty = True -urlpatterns = [url(r"^graphql/inherited/$", CustomGraphQLView.as_view())] +urlpatterns = [path("graphql/inherited/", CustomGraphQLView.as_view())] diff --git a/graphene_django/tests/urls_pretty.py b/graphene_django/tests/urls_pretty.py index 1133c87..6275934 100644 --- a/graphene_django/tests/urls_pretty.py +++ b/graphene_django/tests/urls_pretty.py @@ -1,6 +1,6 @@ -from django.conf.urls import url +from django.urls import path from ..views import GraphQLView from .schema_view import schema -urlpatterns = [url(r"^graphql", GraphQLView.as_view(schema=schema, pretty=True))] +urlpatterns = [path("graphql", GraphQLView.as_view(schema=schema, pretty=True))] diff --git a/graphene_django/utils/testing.py b/graphene_django/utils/testing.py index 763196d..b91a02f 100644 --- a/graphene_django/utils/testing.py +++ b/graphene_django/utils/testing.py @@ -3,7 +3,7 @@ import warnings from django.test import Client, TestCase, TransactionTestCase -DEFAULT_GRAPHQL_URL = "/graphql/" +DEFAULT_GRAPHQL_URL = "/graphql" def graphql_query( diff --git a/tox.ini b/tox.ini index 7128afe..d65839a 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,8 @@ [tox] envlist = - py{36,37,38,39}-django{22,30,31,32,main}, + py{36,37,38,39}-django{22,30,31}, + py{36,37,38,39,310}-django32, + py{38,39,310}-django{40,main}, black,flake8 [gh-actions] @@ -9,6 +11,7 @@ python = 3.7: py37 3.8: py38 3.9: py39 + 3.10: py310 [gh-actions:env] DJANGO = @@ -16,6 +19,7 @@ DJANGO = 3.0: django30 3.1: django31 3.2: django32 + 4.0: django40 main: djangomain [testenv] @@ -26,12 +30,11 @@ setenv = deps = -e.[test] psycopg2-binary - django20: Django>=2.0,<2.1 - django21: Django>=2.1,<2.2 django22: Django>=2.2,<3.0 - django30: Django>=3.0a1,<3.1 + django30: Django>=3.0,<3.1 django31: Django>=3.1,<3.2 - django32: Django>=3.2a1,<3.3 + django32: Django>=3.2,<4.0 + django40: Django>=4.0,<4.1 djangomain: https://github.com/django/django/archive/main.zip commands = {posargs:py.test --cov=graphene_django graphene_django examples} From e1a7d1983314174c91ede1ebbfe35a9009cf6268 Mon Sep 17 00:00:00 2001 From: Jarkko Piiroinen <183207+jmp@users.noreply.github.com> Date: Tue, 18 Jan 2022 16:03:08 +0200 Subject: [PATCH 29/52] Convert DecimalField to Decimal instead of Float in DRF and form converters (#1277) * Convert serializer DecimalField to Decimal * Convert form DecimalField to Decimal --- graphene_django/filter/tests/test_fields.py | 4 ++-- graphene_django/forms/converter.py | 22 +++++++++++++++++-- graphene_django/forms/tests/test_converter.py | 5 +++-- .../rest_framework/serializer_converter.py | 6 ++++- .../tests/test_field_converter.py | 4 ++-- 5 files changed, 32 insertions(+), 9 deletions(-) diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index 17b4630..7d440f4 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -5,7 +5,7 @@ import pytest from django.db.models import TextField, Value from django.db.models.functions import Concat -from graphene import Argument, Boolean, Field, Float, ObjectType, Schema, String +from graphene import Argument, Boolean, Decimal, Field, ObjectType, Schema, String from graphene.relay import Node from graphene_django import DjangoObjectType from graphene_django.forms import GlobalIDFormField, GlobalIDMultipleChoiceField @@ -401,7 +401,7 @@ def test_filterset_descriptions(): field = DjangoFilterConnectionField(ArticleNode, filterset_class=ArticleIdFilter) max_time = field.args["max_time"] assert isinstance(max_time, Argument) - assert max_time.type == Float + assert max_time.type == Decimal assert max_time.description == "The maximum time" diff --git a/graphene_django/forms/converter.py b/graphene_django/forms/converter.py index b64e478..47eb51d 100644 --- a/graphene_django/forms/converter.py +++ b/graphene_django/forms/converter.py @@ -3,7 +3,19 @@ from functools import singledispatch from django import forms from django.core.exceptions import ImproperlyConfigured -from graphene import ID, Boolean, Float, Int, List, String, UUID, Date, DateTime, Time +from graphene import ( + ID, + Boolean, + Decimal, + Float, + Int, + List, + String, + UUID, + Date, + DateTime, + Time, +) from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField @@ -57,12 +69,18 @@ def convert_form_field_to_nullboolean(field): return Boolean(description=get_form_field_description(field)) -@convert_form_field.register(forms.DecimalField) @convert_form_field.register(forms.FloatField) def convert_form_field_to_float(field): return Float(description=get_form_field_description(field), required=field.required) +@convert_form_field.register(forms.DecimalField) +def convert_form_field_to_decimal(field): + return Decimal( + description=get_form_field_description(field), required=field.required + ) + + @convert_form_field.register(forms.MultipleChoiceField) def convert_form_field_to_string_list(field): return List( diff --git a/graphene_django/forms/tests/test_converter.py b/graphene_django/forms/tests/test_converter.py index ccf630f..05584a5 100644 --- a/graphene_django/forms/tests/test_converter.py +++ b/graphene_django/forms/tests/test_converter.py @@ -6,6 +6,7 @@ from graphene import ( String, Int, Boolean, + Decimal, Float, ID, UUID, @@ -97,8 +98,8 @@ def test_should_float_convert_float(): assert_conversion(forms.FloatField, Float) -def test_should_decimal_convert_float(): - assert_conversion(forms.DecimalField, Float) +def test_should_decimal_convert_decimal(): + assert_conversion(forms.DecimalField, Decimal) def test_should_multiple_choice_convert_list(): diff --git a/graphene_django/rest_framework/serializer_converter.py b/graphene_django/rest_framework/serializer_converter.py index b26e5e6..9835475 100644 --- a/graphene_django/rest_framework/serializer_converter.py +++ b/graphene_django/rest_framework/serializer_converter.py @@ -110,11 +110,15 @@ def convert_serializer_field_to_bool(field): @get_graphene_type_from_serializer_field.register(serializers.FloatField) -@get_graphene_type_from_serializer_field.register(serializers.DecimalField) def convert_serializer_field_to_float(field): return graphene.Float +@get_graphene_type_from_serializer_field.register(serializers.DecimalField) +def convert_serializer_field_to_decimal(field): + return graphene.Decimal + + @get_graphene_type_from_serializer_field.register(serializers.DateTimeField) def convert_serializer_field_to_datetime_time(field): return graphene.types.datetime.DateTime diff --git a/graphene_django/rest_framework/tests/test_field_converter.py b/graphene_django/rest_framework/tests/test_field_converter.py index daa8349..4858365 100644 --- a/graphene_django/rest_framework/tests/test_field_converter.py +++ b/graphene_django/rest_framework/tests/test_field_converter.py @@ -133,9 +133,9 @@ def test_should_float_convert_float(): assert_conversion(serializers.FloatField, graphene.Float) -def test_should_decimal_convert_float(): +def test_should_decimal_convert_decimal(): assert_conversion( - serializers.DecimalField, graphene.Float, max_digits=4, decimal_places=2 + serializers.DecimalField, graphene.Decimal, max_digits=4, decimal_places=2 ) From 775644b5369bdc5fbb45d3535ae391a069ebf9d4 Mon Sep 17 00:00:00 2001 From: Keith Date: Sat, 22 Jan 2022 12:04:30 -0800 Subject: [PATCH 30/52] Update requirements to the official graphene 3.0 release (#1290) --- graphene_django/fields.py | 4 +++- setup.py | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/graphene_django/fields.py b/graphene_django/fields.py index e1972c7..eb932c1 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -1,12 +1,14 @@ from functools import partial from django.db.models.query import QuerySet -from graphql_relay.connection.arrayconnection import ( + +from graphql_relay.connection.array_connection import ( connection_from_array_slice, cursor_to_offset, get_offset_with_default, offset_to_cursor, ) + from promise import Promise from graphene import Int, NonNull diff --git a/setup.py b/setup.py index fd403c0..1762760 100644 --- a/setup.py +++ b/setup.py @@ -60,8 +60,9 @@ setup( keywords="api graphql protocol rest relay graphene", packages=find_packages(exclude=["tests", "examples", "examples.*"]), install_requires=[ - "graphene>=3.0.0b5,<4", + "graphene>=3.0,<4", "graphql-core>=3.1.0,<4", + "graphql-relay>=3.1.1,<4", "Django>=2.2", "promise>=2.1", "text-unidecode", From bf8fd7696b2aa35489b97956915f596527441f55 Mon Sep 17 00:00:00 2001 From: Peter Paul Kiefer Date: Sat, 12 Feb 2022 15:31:45 +0100 Subject: [PATCH 31/52] fixed broken links to graphene filter documentation (master->main) (#1309) Co-authored-by: Peter Paul Kiefer --- docs/filtering.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/filtering.rst b/docs/filtering.rst index 934bad6..16002c2 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -2,8 +2,8 @@ Filtering ========= Graphene integrates with -`django-filter `__ to provide filtering of results. -See the `usage documentation `__ +`django-filter `__ to provide filtering of results. +See the `usage documentation `__ for details on the format for ``filter_fields``. This filtering is automatically available when implementing a ``relay.Node``. @@ -34,7 +34,7 @@ Filterable fields The ``filter_fields`` parameter is used to specify the fields which can be filtered upon. The value specified here is passed directly to ``django-filter``, so see the `filtering -documentation `__ +documentation `__ for full details on the range of options available. For example: @@ -192,7 +192,7 @@ in unison with the ``filter_fields`` parameter: all_animals = DjangoFilterConnectionField(AnimalNode) -The context argument is passed on as the `request argument `__ +The context argument is passed on as the `request argument `__ in a ``django_filters.FilterSet`` instance. You can use this to customize your filters to be context-dependent. We could modify the ``AnimalFilter`` above to pre-filter animals owned by the authenticated user (set in ``context.user``). From 0bb9f1ca60458050911eb17ab2f0e1203561c91f Mon Sep 17 00:00:00 2001 From: Peter Paul Kiefer Date: Sun, 13 Feb 2022 06:50:53 +0100 Subject: [PATCH 32/52] I found another wrong link in the filter dokumentation see #1309 (#1311) * fixed broken links to graphene filter documentation (master->main) * #1295 There is still a wrong link to github The referenced example is in main branch but the link goes to the master branch which still exists. Co-authored-by: Peter Paul Kiefer --- docs/filtering.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/filtering.rst b/docs/filtering.rst index 16002c2..fb686a1 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -26,7 +26,7 @@ After installing ``django-filter`` you'll need to add the application in the ``s ] Note: The techniques below are demoed in the `cookbook example -app `__. +app `__. Filterable fields ----------------- From f6ec0689c18929344c79ae363d2e3d5628fa4a2d Mon Sep 17 00:00:00 2001 From: Aaron Forsander Date: Thu, 3 Mar 2022 08:58:48 -0500 Subject: [PATCH 33/52] Fix documentation references: op_name -> operation_name (#1312) --- docs/testing.rst | 10 +++++----- graphene_django/utils/testing.py | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/testing.rst b/docs/testing.rst index 65b6f64..fb0a85d 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -27,7 +27,7 @@ Usage: } } ''', - op_name='myModel' + operation_name='myModel' ) content = json.loads(response.content) @@ -48,7 +48,7 @@ Usage: } } ''', - op_name='myModel', + operation_name='myModel', variables={'id': 1} ) @@ -72,7 +72,7 @@ Usage: } } ''', - op_name='myMutation', + operation_name='myMutation', input_data={'my_field': 'foo', 'other_field': 'bar'} ) @@ -107,7 +107,7 @@ Usage: } } ''', - op_name='myMutation', + operation_name='myMutation', input_data={'my_field': 'foo', 'other_field': 'bar'} ) @@ -147,7 +147,7 @@ To use pytest define a simple fixture using the query helper below } } ''', - op_name='myModel' + operation_name='myModel' ) content = json.loads(response.content) diff --git a/graphene_django/utils/testing.py b/graphene_django/utils/testing.py index b91a02f..f94c385 100644 --- a/graphene_django/utils/testing.py +++ b/graphene_django/utils/testing.py @@ -19,7 +19,7 @@ def graphql_query( Args: query (string) - GraphQL query to run operation_name (string) - If the query is a mutation or named query, you must - supply the op_name. For annon queries ("{ ... }"), + supply the operation_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``, @@ -78,7 +78,7 @@ class GraphQLTestMixin(object): Args: query (string) - GraphQL query to run operation_name (string) - If the query is a mutation or named query, you must - supply the op_name. For annon queries ("{ ... }"), + supply the operation_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``, @@ -89,7 +89,7 @@ class GraphQLTestMixin(object): headers (dict) - If provided, the headers in POST request to GRAPHQL_URL will be set to this value. Keys should be prepended with "HTTP_" (e.g. to specify the "Authorization" HTTP header, - use "HTTP_AUTHORIZATION" as the key). + use "HTTP_AUTHORIZATION" as the key). Returns: Response object from client From 5f1731dca31bc1b8df766a73011be02744f59c5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolai=20R=C3=B8ed=20Kristiansen?= Date: Mon, 15 Aug 2022 11:41:39 +0200 Subject: [PATCH 34/52] Fix: Use .formatted instead of format_error (#1327) & Fix tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 👽 Use .formatted instead of format_error * ✅ Fix test with newer graphene null default values (graphql-python/graphene@03277a5) no more trailing newlines --- .../tests/test_array_field_exact_filter.py | 5 +---- .../filter/tests/test_enum_filtering.py | 5 +---- graphene_django/filter/tests/test_fields.py | 10 ++++------ .../filter/tests/test_typed_filter.py | 2 +- graphene_django/tests/test_command.py | 3 +-- graphene_django/tests/test_types.py | 20 +++++++------------ graphene_django/tests/test_views.py | 5 ----- graphene_django/views.py | 3 +-- 8 files changed, 16 insertions(+), 37 deletions(-) diff --git a/graphene_django/filter/tests/test_array_field_exact_filter.py b/graphene_django/filter/tests/test_array_field_exact_filter.py index cd72868..10e32ef 100644 --- a/graphene_django/filter/tests/test_array_field_exact_filter.py +++ b/graphene_django/filter/tests/test_array_field_exact_filter.py @@ -120,10 +120,7 @@ def test_array_field_filter_schema_type(Query): "randomField": "[Boolean!]", } filters_str = ", ".join( - [ - f"{filter_field}: {gql_type} = null" - for filter_field, gql_type in filters.items() - ] + [f"{filter_field}: {gql_type}" for filter_field, gql_type in filters.items()] ) assert ( f"type Query {{\n events({filters_str}): EventTypeConnection\n}}" in schema_str diff --git a/graphene_django/filter/tests/test_enum_filtering.py b/graphene_django/filter/tests/test_enum_filtering.py index 09c69b3..4fe7ddd 100644 --- a/graphene_django/filter/tests/test_enum_filtering.py +++ b/graphene_django/filter/tests/test_enum_filtering.py @@ -152,9 +152,6 @@ def test_filter_enum_field_schema_type(schema): "reporter_AChoice_In": "[TestsReporterAChoiceChoices]", } filters_str = ", ".join( - [ - f"{filter_field}: {gql_type} = null" - for filter_field, gql_type in filters.items() - ] + [f"{filter_field}: {gql_type}" for filter_field, gql_type in filters.items()] ) assert f" allArticles({filters_str}): ArticleTypeConnection\n" in schema_str diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index 7d440f4..370c894 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -1008,7 +1008,7 @@ def test_integer_field_filter_type(): assert str(schema) == dedent( """\ type Query { - pets(offset: Int = null, before: String = null, after: String = null, first: Int = null, last: Int = null, age: Int = null): PetTypeConnection + pets(offset: Int, before: String, after: String, first: Int, last: Int, age: Int): PetTypeConnection } type PetTypeConnection { @@ -1056,8 +1056,7 @@ def test_integer_field_filter_type(): interface Node { \"""The ID of the object\""" id: ID! - } - """ + }""" ) @@ -1077,7 +1076,7 @@ def test_other_filter_types(): assert str(schema) == dedent( """\ type Query { - pets(offset: Int = null, before: String = null, after: String = null, first: Int = null, last: Int = null, age: Int = null, age_Isnull: Boolean = null, age_Lt: Int = null): PetTypeConnection + pets(offset: Int, before: String, after: String, first: Int, last: Int, age: Int, age_Isnull: Boolean, age_Lt: Int): PetTypeConnection } type PetTypeConnection { @@ -1125,8 +1124,7 @@ def test_other_filter_types(): interface Node { \"""The ID of the object\""" id: ID! - } - """ + }""" ) diff --git a/graphene_django/filter/tests/test_typed_filter.py b/graphene_django/filter/tests/test_typed_filter.py index b903b59..cc0bafe 100644 --- a/graphene_django/filter/tests/test_typed_filter.py +++ b/graphene_django/filter/tests/test_typed_filter.py @@ -98,7 +98,7 @@ def test_typed_filter_schema(schema): ) for filter_field, gql_type in filters.items(): - assert "{}: {} = null".format(filter_field, gql_type) in all_articles_filters + assert "{}: {}".format(filter_field, gql_type) in all_articles_filters def test_typed_filters_work(schema): diff --git a/graphene_django/tests/test_command.py b/graphene_django/tests/test_command.py index 70116b8..11a15bc 100644 --- a/graphene_django/tests/test_command.py +++ b/graphene_django/tests/test_command.py @@ -53,6 +53,5 @@ def test_generate_graphql_file_on_call_graphql_schema(): """\ type Query { hi: String - } - """ + }""" ) diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index bde72c7..4885917 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -183,7 +183,7 @@ def test_schema_representation(): pets: [Reporter!]! aChoice: TestsReporterAChoiceChoices reporterType: TestsReporterReporterTypeChoices - articles(offset: Int = null, before: String = null, after: String = null, first: Int = null, last: Int = null): ArticleConnection! + articles(offset: Int, before: String, after: String, first: Int, last: Int): ArticleConnection! } \"""An enumeration.\""" @@ -244,8 +244,7 @@ def test_schema_representation(): \"""The ID of the object\""" id: ID! ): Node - } - """ + }""" ) assert str(schema) == expected @@ -525,8 +524,7 @@ class TestDjangoObjectType: id: ID! kind: String! cuteness: Int! - } - """ + }""" ) def test_django_objecttype_convert_choices_enum_list(self, PetModel): @@ -560,8 +558,7 @@ class TestDjangoObjectType: \"""Dog\""" DOG - } - """ + }""" ) def test_django_objecttype_convert_choices_enum_empty_list(self, PetModel): @@ -586,8 +583,7 @@ class TestDjangoObjectType: id: ID! kind: String! cuteness: Int! - } - """ + }""" ) def test_django_objecttype_convert_choices_enum_naming_collisions( @@ -621,8 +617,7 @@ class TestDjangoObjectType: \"""Dog\""" DOG - } - """ + }""" ) def test_django_objecttype_choices_custom_enum_name( @@ -660,8 +655,7 @@ class TestDjangoObjectType: \"""Dog\""" DOG - } - """ + }""" ) diff --git a/graphene_django/tests/test_views.py b/graphene_django/tests/test_views.py index 945fa87..c2f18c3 100644 --- a/graphene_django/tests/test_views.py +++ b/graphene_django/tests/test_views.py @@ -109,12 +109,10 @@ def test_reports_validation_errors(client): { "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 9}], - "path": None, }, { "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 21}], - "path": None, }, ] } @@ -135,8 +133,6 @@ def test_errors_when_missing_operation_name(client): "errors": [ { "message": "Must provide operation name if query contains multiple operations.", - "locations": None, - "path": None, } ] } @@ -477,7 +473,6 @@ def test_handles_syntax_errors_caught_by_graphql(client): { "locations": [{"column": 1, "line": 1}], "message": "Syntax Error: Unexpected Name 'syntaxerror'.", - "path": None, } ] } diff --git a/graphene_django/views.py b/graphene_django/views.py index c23b020..f533f70 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -11,7 +11,6 @@ from django.views.decorators.csrf import ensure_csrf_cookie from django.views.generic import View from graphql import OperationType, get_operation_ast, parse, validate from graphql.error import GraphQLError -from graphql.error import format_error as format_graphql_error from graphql.execution import ExecutionResult from graphene import Schema @@ -387,7 +386,7 @@ class GraphQLView(View): @staticmethod def format_error(error): if isinstance(error, GraphQLError): - return format_graphql_error(error) + return error.formatted return {"message": str(error)} From 2aeb86ba3bed7dc821cc8508e1dca8297aa7dea4 Mon Sep 17 00:00:00 2001 From: Thomas Leonard <64223923+tcleonard@users.noreply.github.com> Date: Tue, 6 Sep 2022 14:00:13 +0200 Subject: [PATCH 35/52] fix: backward pagination indexing error when using bigger last argument than total number of elements (#1344) Co-authored-by: Thomas Leonard --- graphene_django/fields.py | 24 ++++----- graphene_django/tests/test_query.py | 75 +++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 11 deletions(-) diff --git a/graphene_django/fields.py b/graphene_django/fields.py index eb932c1..c881456 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -146,36 +146,38 @@ class DjangoConnectionField(ConnectionField): iterable = maybe_queryset(iterable) if isinstance(iterable, QuerySet): - list_length = iterable.count() + array_length = iterable.count() else: - list_length = len(iterable) - list_slice_length = ( - min(max_limit, list_length) if max_limit is not None else list_length + array_length = len(iterable) + array_slice_length = ( + min(max_limit, array_length) if max_limit is not None else array_length ) # If after is higher than list_length, connection_from_list_slice # would try to do a negative slicing which makes django throw an # AssertionError - after = min(get_offset_with_default(args.get("after"), -1) + 1, list_length) + slice_start = min( + get_offset_with_default(args.get("after"), -1) + 1, array_length + ) if max_limit is not None and args.get("first", None) is None: if args.get("last", None) is not None: - after = list_length - args["last"] + slice_start = max(array_length - args["last"], 0) else: args["first"] = max_limit connection = connection_from_array_slice( - iterable[after:], + iterable[slice_start:], args, - slice_start=after, - array_length=list_length, - array_slice_length=list_slice_length, + slice_start=slice_start, + array_length=array_length, + array_slice_length=array_slice_length, connection_type=partial(connection_adapter, connection), edge_type=connection.Edge, page_info_type=page_info_adapter, ) connection.iterable = iterable - connection.length = list_length + connection.length = array_length return connection @classmethod diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index aabe19c..5cbf90e 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1593,3 +1593,78 @@ def test_connection_should_allow_offset_filtering_with_after(): "allReporters": {"edges": [{"node": {"firstName": "Jane", "lastName": "Roe"}},]} } assert result.data == expected + + +def test_connection_should_succeed_if_last_higher_than_number_of_objects(): + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + fields = "__all__" + + class Query(graphene.ObjectType): + all_reporters = DjangoConnectionField(ReporterType) + + schema = graphene.Schema(query=Query) + query = """ + query ReporterPromiseConnectionQuery ($last: Int) { + allReporters(last: $last) { + edges { + node { + firstName + lastName + } + } + } + } + """ + + result = schema.execute(query, variable_values=dict(last=2)) + assert not result.errors + expected = {"allReporters": {"edges": []}} + assert result.data == expected + + Reporter.objects.create(first_name="John", last_name="Doe") + Reporter.objects.create(first_name="Some", last_name="Guy") + Reporter.objects.create(first_name="Jane", last_name="Roe") + Reporter.objects.create(first_name="Some", last_name="Lady") + + result = schema.execute(query, variable_values=dict(last=2)) + assert not result.errors + expected = { + "allReporters": { + "edges": [ + {"node": {"firstName": "Jane", "lastName": "Roe"}}, + {"node": {"firstName": "Some", "lastName": "Lady"}}, + ] + } + } + assert result.data == expected + + result = schema.execute(query, variable_values=dict(last=4)) + assert not result.errors + expected = { + "allReporters": { + "edges": [ + {"node": {"firstName": "John", "lastName": "Doe"}}, + {"node": {"firstName": "Some", "lastName": "Guy"}}, + {"node": {"firstName": "Jane", "lastName": "Roe"}}, + {"node": {"firstName": "Some", "lastName": "Lady"}}, + ] + } + } + assert result.data == expected + + result = schema.execute(query, variable_values=dict(last=20)) + assert not result.errors + expected = { + "allReporters": { + "edges": [ + {"node": {"firstName": "John", "lastName": "Doe"}}, + {"node": {"firstName": "Some", "lastName": "Guy"}}, + {"node": {"firstName": "Jane", "lastName": "Roe"}}, + {"node": {"firstName": "Some", "lastName": "Lady"}}, + ] + } + } + assert result.data == expected From 8ae576394ec9922cc1ab962697c2239b6a9a3325 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolai=20R=C3=B8ed=20Kristiansen?= Date: Mon, 19 Sep 2022 14:31:04 +0200 Subject: [PATCH 36/52] =?UTF-8?q?=F0=9F=92=A5=20Stop=20supporting=20EOL=20?= =?UTF-8?q?djangos=20and=20pythons=20(#1337)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 💥 Stop supporting EOL djangos and pythons * 👷 Run only supported version in test workflow --- .github/workflows/tests.yml | 10 +++------- setup.py | 9 ++++----- tox.ini | 14 ++++---------- 3 files changed, 11 insertions(+), 22 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index c63742a..c2cdc99 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,15 +8,11 @@ jobs: strategy: max-parallel: 4 matrix: - django: ["2.2", "3.0", "3.1", "3.2"] - python-version: ["3.6", "3.7", "3.8", "3.9"] + django: ["3.2", "4.0", "4.1"] + python-version: ["3.8", "3.9", "3.10"] include: - django: "3.2" - python-version: "3.10" - - django: "4.0" - python-version: "3.10" - - django: "main" - python-version: "3.10" + python-version: "3.7" steps: - uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} diff --git a/setup.py b/setup.py index 1762760..306ec33 100644 --- a/setup.py +++ b/setup.py @@ -46,16 +46,15 @@ setup( "Intended Audience :: Developers", "Topic :: Software Development :: Libraries", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "Programming Language :: Python :: Implementation :: PyPy", "Framework :: Django", - "Framework :: Django :: 2.2", - "Framework :: Django :: 3.0", - "Framework :: Django :: 3.1", "Framework :: Django :: 3.2", + "Framework :: Django :: 4.0", + "Framework :: Django :: 4.1", ], keywords="api graphql protocol rest relay graphene", packages=find_packages(exclude=["tests", "examples", "examples.*"]), @@ -63,7 +62,7 @@ setup( "graphene>=3.0,<4", "graphql-core>=3.1.0,<4", "graphql-relay>=3.1.1,<4", - "Django>=2.2", + "Django>=3.2", "promise>=2.1", "text-unidecode", ], diff --git a/tox.ini b/tox.ini index d65839a..11b4893 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,11 @@ [tox] envlist = - py{36,37,38,39}-django{22,30,31}, - py{36,37,38,39,310}-django32, - py{38,39,310}-django{40,main}, + py{37,38,39,310}-django32, + py{38,39,310}-django{40,41,main}, black,flake8 [gh-actions] python = - 3.6: py36 3.7: py37 3.8: py38 3.9: py39 @@ -15,11 +13,9 @@ python = [gh-actions:env] DJANGO = - 2.2: django22 - 3.0: django30 - 3.1: django31 3.2: django32 4.0: django40 + 4.1: django41 main: djangomain [testenv] @@ -30,11 +26,9 @@ setenv = deps = -e.[test] psycopg2-binary - django22: Django>=2.2,<3.0 - django30: Django>=3.0,<3.1 - django31: Django>=3.1,<3.2 django32: Django>=3.2,<4.0 django40: Django>=4.0,<4.1 + django41: Django>=4.1,<4.2 djangomain: https://github.com/django/django/archive/main.zip commands = {posargs:py.test --cov=graphene_django graphene_django examples} From 42a40b4df0f9f90e918c2b11985079f52f8de73b Mon Sep 17 00:00:00 2001 From: Thomas Leonard <64223923+tcleonard@users.noreply.github.com> Date: Thu, 22 Sep 2022 10:26:21 +0100 Subject: [PATCH 37/52] chore: update dev dependencies (#1345) Co-authored-by: Thomas Leonard --- Makefile | 17 ++++++++--------- graphene_django/fields.py | 5 ++++- .../filter/filters/global_id_filter.py | 2 +- graphene_django/filter/filterset.py | 10 ++++------ graphene_django/filter/tests/conftest.py | 8 ++++---- .../filter/tests/test_enum_filtering.py | 14 ++++++++++---- graphene_django/filter/tests/test_fields.py | 16 +++++++++++++--- .../filter/tests/test_typed_filter.py | 12 +++--------- graphene_django/filter/utils.py | 4 +++- graphene_django/tests/test_query.py | 16 +++++++++++++--- graphene_django/types.py | 2 +- .../utils/tests/test_str_converters.py | 2 +- setup.py | 14 +++++++------- 13 files changed, 72 insertions(+), 50 deletions(-) diff --git a/Makefile b/Makefile index b850ae8..d8ceaef 100644 --- a/Makefile +++ b/Makefile @@ -1,22 +1,21 @@ +.PHONY: help +help: + @echo "Please use \`make ' where is one of" + @grep -E '^\.PHONY: [a-zA-Z_-]+ .*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = "(: |##)"}; {printf "\033[36m%-30s\033[0m %s\n", $$2, $$3}' + .PHONY: dev-setup ## Install development dependencies dev-setup: pip install -e ".[dev]" -.PHONY: install-dev -install-dev: dev-setup # Alias install-dev -> dev-setup - -.PHONY: tests +.PHONY: tests ## Run unit tests tests: py.test graphene_django --cov=graphene_django -vv -.PHONY: test -test: tests # Alias test -> tests - -.PHONY: format +.PHONY: format ## Format code format: black --exclude "/migrations/" graphene_django examples setup.py -.PHONY: lint +.PHONY: lint ## Lint code lint: flake8 graphene_django examples diff --git a/graphene_django/fields.py b/graphene_django/fields.py index c881456..f26f851 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -69,7 +69,10 @@ class DjangoListField(Field): _type = _type.of_type django_object_type = _type.of_type.of_type return partial( - self.list_resolver, django_object_type, resolver, self.get_manager(), + self.list_resolver, + django_object_type, + resolver, + self.get_manager(), ) diff --git a/graphene_django/filter/filters/global_id_filter.py b/graphene_django/filter/filters/global_id_filter.py index a612a8a..da16585 100644 --- a/graphene_django/filter/filters/global_id_filter.py +++ b/graphene_django/filter/filters/global_id_filter.py @@ -13,7 +13,7 @@ class GlobalIDFilter(Filter): field_class = GlobalIDFormField def filter(self, qs, value): - """ Convert the filter value to a primary key before filtering """ + """Convert the filter value to a primary key before filtering""" _id = None if value is not None: _, _id = from_global_id(value) diff --git a/graphene_django/filter/filterset.py b/graphene_django/filter/filterset.py index b3333bf..57c35af 100644 --- a/graphene_django/filter/filterset.py +++ b/graphene_django/filter/filterset.py @@ -18,8 +18,8 @@ GRAPHENE_FILTER_SET_OVERRIDES = { class GrapheneFilterSetMixin(BaseFilterSet): - """ A django_filters.filterset.BaseFilterSet with default filter overrides - to handle global IDs """ + """A django_filters.filterset.BaseFilterSet with default filter overrides + to handle global IDs""" FILTER_DEFAULTS = dict( itertools.chain( @@ -29,8 +29,7 @@ class GrapheneFilterSetMixin(BaseFilterSet): def setup_filterset(filterset_class): - """ Wrap a provided filterset in Graphene-specific functionality - """ + """Wrap a provided filterset in Graphene-specific functionality""" return type( "Graphene{}".format(filterset_class.__name__), (filterset_class, GrapheneFilterSetMixin), @@ -39,8 +38,7 @@ def setup_filterset(filterset_class): def custom_filterset_factory(model, filterset_base_class=FilterSet, **meta): - """ Create a filterset for the given model using the provided meta data - """ + """Create a filterset for the given model using the provided meta data""" meta.update({"model": model}) meta_class = type(str("Meta"), (object,), meta) filterset = type( diff --git a/graphene_django/filter/tests/conftest.py b/graphene_django/filter/tests/conftest.py index 57924af..e2bba68 100644 --- a/graphene_django/filter/tests/conftest.py +++ b/graphene_django/filter/tests/conftest.py @@ -89,10 +89,10 @@ def Query(EventType): def resolve_events(self, info, **kwargs): events = [ - Event(name="Live Show", tags=["concert", "music", "rock"],), - Event(name="Musical", tags=["movie", "music"],), - Event(name="Ballet", tags=["concert", "dance"],), - Event(name="Speech", tags=[],), + Event(name="Live Show", tags=["concert", "music", "rock"]), + Event(name="Musical", tags=["movie", "music"]), + Event(name="Ballet", tags=["concert", "dance"]), + Event(name="Speech", tags=[]), ] STORE["events"] = events diff --git a/graphene_django/filter/tests/test_enum_filtering.py b/graphene_django/filter/tests/test_enum_filtering.py index 4fe7ddd..a284d08 100644 --- a/graphene_django/filter/tests/test_enum_filtering.py +++ b/graphene_django/filter/tests/test_enum_filtering.py @@ -54,13 +54,13 @@ def reporter_article_data(): first_name="Jane", last_name="Doe", email="janedoe@example.com", a_choice=2 ) Article.objects.create( - headline="Article Node 1", reporter=john, editor=john, lang="es", + headline="Article Node 1", reporter=john, editor=john, lang="es" ) Article.objects.create( - headline="Article Node 2", reporter=john, editor=john, lang="en", + headline="Article Node 2", reporter=john, editor=john, lang="en" ) Article.objects.create( - headline="Article Node 3", reporter=jane, editor=jane, lang="en", + headline="Article Node 3", reporter=jane, editor=jane, lang="en" ) @@ -80,7 +80,13 @@ def test_filter_enum_on_connection(schema, reporter_article_data): } """ - expected = {"allArticles": {"edges": [{"node": {"headline": "Article Node 1"}},]}} + expected = { + "allArticles": { + "edges": [ + {"node": {"headline": "Article Node 1"}}, + ] + } + } result = schema.execute(query) assert not result.errors diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index 370c894..fe4ae87 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -1224,7 +1224,7 @@ def test_filter_filterset_based_on_mixin(): } } - result = schema.execute(query, variable_values={"email": reporter_1.email},) + result = schema.execute(query, variable_values={"email": reporter_1.email}) assert not result.errors assert result.data == expected @@ -1265,13 +1265,23 @@ def test_filter_string_contains(): result = schema.execute(query, variables={"filter": "Ja"}) assert not result.errors assert result.data == { - "people": {"edges": [{"node": {"name": "Jack"}}, {"node": {"name": "Jane"}},]} + "people": { + "edges": [ + {"node": {"name": "Jack"}}, + {"node": {"name": "Jane"}}, + ] + } } result = schema.execute(query, variables={"filter": "o"}) assert not result.errors assert result.data == { - "people": {"edges": [{"node": {"name": "Joe"}}, {"node": {"name": "Bob"}},]} + "people": { + "edges": [ + {"node": {"name": "Joe"}}, + {"node": {"name": "Bob"}}, + ] + } } diff --git a/graphene_django/filter/tests/test_typed_filter.py b/graphene_django/filter/tests/test_typed_filter.py index cc0bafe..a7edc56 100644 --- a/graphene_django/filter/tests/test_typed_filter.py +++ b/graphene_django/filter/tests/test_typed_filter.py @@ -103,15 +103,9 @@ def test_typed_filter_schema(schema): def test_typed_filters_work(schema): reporter = Reporter.objects.create(first_name="John", last_name="Doe", email="") - Article.objects.create( - headline="A", reporter=reporter, editor=reporter, lang="es", - ) - Article.objects.create( - headline="B", reporter=reporter, editor=reporter, lang="es", - ) - Article.objects.create( - headline="C", reporter=reporter, editor=reporter, lang="en", - ) + Article.objects.create(headline="A", reporter=reporter, editor=reporter, lang="es") + Article.objects.create(headline="B", reporter=reporter, editor=reporter, lang="es") + Article.objects.create(headline="C", reporter=reporter, editor=reporter, lang="en") query = "query { articles (lang_In: [ES]) { edges { node { headline } } } }" diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index cd05a87..ebd2a00 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -94,7 +94,9 @@ def get_filtering_args_from_filterset(filterset_class, type): field_type = graphene.List(field_type) args[name] = graphene.Argument( - field_type, description=filter_field.label, required=required, + field_type, + description=filter_field.label, + required=required, ) return args diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 5cbf90e..f1815d7 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1480,7 +1480,11 @@ def test_connection_should_enable_offset_filtering(): result = schema.execute(query) assert not result.errors expected = { - "allReporters": {"edges": [{"node": {"firstName": "Some", "lastName": "Guy"}},]} + "allReporters": { + "edges": [ + {"node": {"firstName": "Some", "lastName": "Guy"}}, + ] + } } assert result.data == expected @@ -1521,7 +1525,9 @@ def test_connection_should_enable_offset_filtering_higher_than_max_limit( assert not result.errors expected = { "allReporters": { - "edges": [{"node": {"firstName": "Some", "lastName": "Lady"}},] + "edges": [ + {"node": {"firstName": "Some", "lastName": "Lady"}}, + ] } } assert result.data == expected @@ -1590,7 +1596,11 @@ def test_connection_should_allow_offset_filtering_with_after(): result = schema.execute(query, variable_values=dict(after=after)) assert not result.errors expected = { - "allReporters": {"edges": [{"node": {"firstName": "Jane", "lastName": "Roe"}},]} + "allReporters": { + "edges": [ + {"node": {"firstName": "Jane", "lastName": "Roe"}}, + ] + } } assert result.data == expected diff --git a/graphene_django/types.py b/graphene_django/types.py index d272412..c256f1d 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -216,7 +216,7 @@ class DjangoObjectType(ObjectType): "Creating a DjangoObjectType without either the `fields` " "or the `exclude` option is deprecated. Add an explicit `fields " "= '__all__'` option on DjangoObjectType {class_name} to use all " - "fields".format(class_name=cls.__name__,), + "fields".format(class_name=cls.__name__), DeprecationWarning, stacklevel=2, ) diff --git a/graphene_django/utils/tests/test_str_converters.py b/graphene_django/utils/tests/test_str_converters.py index 6460c4e..d3d33c2 100644 --- a/graphene_django/utils/tests/test_str_converters.py +++ b/graphene_django/utils/tests/test_str_converters.py @@ -6,4 +6,4 @@ def test_to_const(): def test_to_const_unicode(): - assert to_const(u"Skoða þetta unicode stöff") == "SKODA_THETTA_UNICODE_STOFF" + assert to_const("Skoða þetta unicode stöff") == "SKODA_THETTA_UNICODE_STOFF" diff --git a/setup.py b/setup.py index 306ec33..d9aefef 100644 --- a/setup.py +++ b/setup.py @@ -14,22 +14,22 @@ rest_framework_require = ["djangorestframework>=3.6.3"] tests_require = [ - "pytest>=3.6.3", + "pytest>=7.1.3", "pytest-cov", "pytest-random-order", "coveralls", "mock", "pytz", - "django-filter>=2", - "pytest-django>=3.3.2", + "django-filter>=22.1", + "pytest-django>=4.5.2", ] + rest_framework_require dev_requires = [ - "black==19.10b0", - "flake8==3.7.9", - "flake8-black==0.1.1", - "flake8-bugbear==20.1.4", + "black==22.8.0", + "flake8==5.0.4", + "flake8-black==0.3.3", + "flake8-bugbear==22.9.11", ] + tests_require setup( From 3473fe025e1efb5de97040087dbe6edf050ef373 Mon Sep 17 00:00:00 2001 From: Thomas Leonard <64223923+tcleonard@users.noreply.github.com> Date: Thu, 22 Sep 2022 16:01:28 +0100 Subject: [PATCH 38/52] fix: backward pagination (#1346) Co-authored-by: Thomas Leonard Co-authored-by: Laurent --- graphene_django/fields.py | 22 ++++++------ graphene_django/tests/test_query.py | 53 ++++++++++++++++++++--------- 2 files changed, 48 insertions(+), 27 deletions(-) diff --git a/graphene_django/fields.py b/graphene_django/fields.py index f26f851..3c48595 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -152,22 +152,24 @@ class DjangoConnectionField(ConnectionField): array_length = iterable.count() else: array_length = len(iterable) - array_slice_length = ( - min(max_limit, array_length) if max_limit is not None else array_length - ) - # If after is higher than list_length, connection_from_list_slice + # If after is higher than array_length, connection_from_array_slice # would try to do a negative slicing which makes django throw an # AssertionError slice_start = min( - get_offset_with_default(args.get("after"), -1) + 1, array_length + get_offset_with_default(args.get("after"), -1) + 1, + array_length, ) + array_slice_length = array_length - slice_start - if max_limit is not None and args.get("first", None) is None: - if args.get("last", None) is not None: - slice_start = max(array_length - args["last"], 0) - else: - args["first"] = max_limit + # Impose the maximum limit via the `first` field if neither first or last are already provided + # (note that if any of them is provided they must be under max_limit otherwise an error is raised). + if ( + max_limit is not None + and args.get("first", None) is None + and args.get("last", None) is None + ): + args["first"] = max_limit connection = connection_from_array_slice( iterable[slice_start:], diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index f1815d7..207c211 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1243,6 +1243,7 @@ def test_should_have_next_page(graphene_settings): } +@pytest.mark.parametrize("max_limit", [100, 4]) class TestBackwardPagination: def setup_schema(self, graphene_settings, max_limit): graphene_settings.RELAY_CONNECTION_MAX_LIMIT = max_limit @@ -1261,8 +1262,8 @@ class TestBackwardPagination: schema = graphene.Schema(query=Query) return schema - def do_queries(self, schema): - # Simply last 3 + def test_query_last(self, graphene_settings, max_limit): + schema = self.setup_schema(graphene_settings, max_limit=max_limit) query_last = """ query { allReporters(last: 3) { @@ -1282,7 +1283,8 @@ class TestBackwardPagination: e["node"]["firstName"] for e in result.data["allReporters"]["edges"] ] == ["First 3", "First 4", "First 5"] - # Use a combination of first and last + def test_query_first_and_last(self, graphene_settings, max_limit): + schema = self.setup_schema(graphene_settings, max_limit=max_limit) query_first_and_last = """ query { allReporters(first: 4, last: 3) { @@ -1302,7 +1304,8 @@ class TestBackwardPagination: e["node"]["firstName"] for e in result.data["allReporters"]["edges"] ] == ["First 1", "First 2", "First 3"] - # Use a combination of first and last and after + def test_query_first_last_and_after(self, graphene_settings, max_limit): + schema = self.setup_schema(graphene_settings, max_limit=max_limit) query_first_last_and_after = """ query queryAfter($after: String) { allReporters(first: 4, last: 3, after: $after) { @@ -1317,7 +1320,8 @@ class TestBackwardPagination: after = base64.b64encode(b"arrayconnection:0").decode() result = schema.execute( - query_first_last_and_after, variable_values=dict(after=after) + query_first_last_and_after, + variable_values=dict(after=after), ) assert not result.errors assert len(result.data["allReporters"]["edges"]) == 3 @@ -1325,20 +1329,35 @@ class TestBackwardPagination: e["node"]["firstName"] for e in result.data["allReporters"]["edges"] ] == ["First 2", "First 3", "First 4"] - def test_should_query(self, graphene_settings): + def test_query_last_and_before(self, graphene_settings, max_limit): + schema = self.setup_schema(graphene_settings, max_limit=max_limit) + query_first_last_and_after = """ + query queryAfter($before: String) { + allReporters(last: 1, before: $before) { + edges { + node { + firstName + } + } + } + } """ - Backward pagination should work as expected - """ - schema = self.setup_schema(graphene_settings, max_limit=100) - self.do_queries(schema) - def test_should_query_with_low_max_limit(self, graphene_settings): - """ - When doing backward pagination (using last) in combination with a max limit higher than the number of objects - we should really retrieve the last ones. - """ - schema = self.setup_schema(graphene_settings, max_limit=4) - self.do_queries(schema) + result = schema.execute( + query_first_last_and_after, + ) + assert not result.errors + assert len(result.data["allReporters"]["edges"]) == 1 + assert result.data["allReporters"]["edges"][0]["node"]["firstName"] == "First 5" + + before = base64.b64encode(b"arrayconnection:5").decode() + result = schema.execute( + query_first_last_and_after, + variable_values=dict(before=before), + ) + assert not result.errors + assert len(result.data["allReporters"]["edges"]) == 1 + assert result.data["allReporters"]["edges"][0]["node"]["firstName"] == "First 4" def test_should_preserve_prefetch_related(django_assert_num_queries): From 37848fa2dfab6feaa2d91d8e41adf7a619b4eb67 Mon Sep 17 00:00:00 2001 From: Thomas Leonard <64223923+tcleonard@users.noreply.github.com> Date: Thu, 22 Sep 2022 19:09:11 +0100 Subject: [PATCH 39/52] fix: convert Django BigIntegerField to BigInt GraphQL type (#1318) Co-authored-by: Thomas Leonard --- graphene_django/converter.py | 7 ++++++- graphene_django/tests/test_converter.py | 5 +++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/graphene_django/converter.py b/graphene_django/converter.py index c243e82..90b1466 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -24,6 +24,7 @@ from graphene import ( Decimal, ) from graphene.types.json import JSONString +from graphene.types.scalars import BigInt from graphene.utils.str_converters import to_camel_case from graphql import GraphQLError, assert_valid_name from graphql.pyutils import register_description @@ -186,10 +187,14 @@ def convert_field_to_uuid(field, registry=None): ) +@convert_django_field.register(models.BigIntegerField) +def convert_big_int_field(field, registry=None): + return BigInt(description=field.help_text, required=not field.null) + + @convert_django_field.register(models.PositiveIntegerField) @convert_django_field.register(models.PositiveSmallIntegerField) @convert_django_field.register(models.SmallIntegerField) -@convert_django_field.register(models.BigIntegerField) @convert_django_field.register(models.IntegerField) def convert_field_to_int(field, registry=None): return Int(description=get_django_field_description(field), required=not field.null) diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index afd744f..9158b12 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -10,6 +10,7 @@ from graphene import NonNull from graphene.relay import ConnectionField, Node from graphene.types.datetime import Date, DateTime, Time from graphene.types.json import JSONString +from graphene.types.scalars import BigInt from ..compat import ( ArrayField, @@ -140,8 +141,8 @@ def test_should_small_integer_convert_int(): assert_conversion(models.SmallIntegerField, graphene.Int) -def test_should_big_integer_convert_int(): - assert_conversion(models.BigIntegerField, graphene.Int) +def test_should_big_integer_convert_big_int(): + assert_conversion(models.BigIntegerField, BigInt) def test_should_integer_convert_int(): From a53ded611bbe1519742a5254e4cbfa12af0cc2d9 Mon Sep 17 00:00:00 2001 From: Thomas Leonard <64223923+tcleonard@users.noreply.github.com> Date: Thu, 22 Sep 2022 19:09:29 +0100 Subject: [PATCH 40/52] feat: update name of DjangoFilterConnectionField type input to be consistent with graphene (Issue #1316) (#1317) Co-authored-by: Thomas Leonard --- graphene_django/filter/fields.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py index c6dd50e..eeb197e 100644 --- a/graphene_django/filter/fields.py +++ b/graphene_django/filter/fields.py @@ -30,7 +30,7 @@ def convert_enum(data): class DjangoFilterConnectionField(DjangoConnectionField): def __init__( self, - type, + type_, fields=None, order_by=None, extra_filter_meta=None, @@ -44,7 +44,7 @@ class DjangoFilterConnectionField(DjangoConnectionField): self._filtering_args = None self._extra_filter_meta = extra_filter_meta self._base_args = None - super(DjangoFilterConnectionField, self).__init__(type, *args, **kwargs) + super(DjangoFilterConnectionField, self).__init__(type_, *args, **kwargs) @property def args(self): From 4f315c365d36c54b0803a9a029d534ae3bf03fd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yi=C4=9Fit=20Y=2E=20Er?= Date: Thu, 22 Sep 2022 21:10:52 +0300 Subject: [PATCH 41/52] minor fix on schema.py part (#1306) The documentation already suggests importing ObjectType from graphene, graphene.ObjectType is not necessary while defining the Query class. --- docs/tutorial-relay.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst index 3de9022..a27e255 100644 --- a/docs/tutorial-relay.rst +++ b/docs/tutorial-relay.rst @@ -151,7 +151,7 @@ Create ``cookbook/ingredients/schema.py`` and type the following: interfaces = (relay.Node, ) - class Query(graphene.ObjectType): + class Query(ObjectType): category = relay.Node.Field(CategoryNode) all_categories = DjangoFilterConnectionField(CategoryNode) From 56892d7f4b2c4d237f726092a5ec5987631569e6 Mon Sep 17 00:00:00 2001 From: andrei-datcu Date: Thu, 22 Sep 2022 12:13:30 -0600 Subject: [PATCH 42/52] Cast translated description for DecimalField (#1255) * Cast translated description for DecimalField https://github.com/graphql-python/graphene-django/pull/976 casts all the description fields to strings to prevent schema printing from failing whenever the description is a lazy translated string. The `DecimalField` however got in after the v3 merge and it currently misses the cast. * Fix row size --- graphene_django/converter.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 90b1466..2c4fa19 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -210,7 +210,9 @@ def convert_field_to_boolean(field, registry=None): @convert_django_field.register(models.DecimalField) def convert_field_to_decimal(field, registry=None): - return Decimal(description=field.help_text, required=not field.null) + return Decimal( + description=get_django_field_description(field), required=not field.null + ) @convert_django_field.register(models.FloatField) From b2f83eb277ddaca49ad3320038e6baf381890a3e Mon Sep 17 00:00:00 2001 From: Firas K <3097061+firaskafri@users.noreply.github.com> Date: Fri, 23 Sep 2022 11:38:11 +0300 Subject: [PATCH 43/52] Bump version to 3.0.0b8 (#1348) --- 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 999f3de..93a697a 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__ = "3.0.0b7" +__version__ = "3.0.0b8" __all__ = [ "__version__", From 5d81ba04f9cc4643f537ba56aac224f5862f0d38 Mon Sep 17 00:00:00 2001 From: Thomas Leonard <64223923+tcleonard@users.noreply.github.com> Date: Fri, 23 Sep 2022 09:45:02 +0100 Subject: [PATCH 44/52] fix: unit test for graphene pr#1412 (#1315) * Issue #1111: foreign key should also call get_queryset method * fix: test for graphene PR https://github.com/graphql-python/graphene/pull/1412 Co-authored-by: Thomas Leonard --- graphene_django/converter.py | 19 +- graphene_django/tests/models.py | 3 + graphene_django/tests/test_get_queryset.py | 355 +++++++++++++++++++++ graphene_django/tests/test_query.py | 70 +++- setup.py | 3 +- 5 files changed, 445 insertions(+), 5 deletions(-) create mode 100644 graphene_django/tests/test_get_queryset.py diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 2c4fa19..338ab6d 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -308,7 +308,24 @@ def convert_field_to_djangomodel(field, registry=None): if not _type: return - return Field( + class CustomField(Field): + def wrap_resolve(self, parent_resolver): + """ + Implements a custom resolver which go through the `get_node` method to insure that + it goes through the `get_queryset` method of the DjangoObjectType. + """ + resolver = super().wrap_resolve(parent_resolver) + + def custom_resolver(root, info, **args): + fk_obj = resolver(root, info, **args) + if fk_obj is None: + return None + else: + return _type.get_node(info, fk_obj.pk) + + return custom_resolver + + return CustomField( _type, description=get_django_field_description(field), required=not field.null, diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py index 7b76cd3..c26a6d8 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -13,6 +13,9 @@ class Person(models.Model): class Pet(models.Model): name = models.CharField(max_length=30) age = models.PositiveIntegerField() + owner = models.ForeignKey( + "Person", on_delete=models.CASCADE, null=True, blank=True, related_name="pets" + ) class FilmDetails(models.Model): diff --git a/graphene_django/tests/test_get_queryset.py b/graphene_django/tests/test_get_queryset.py new file mode 100644 index 0000000..b2647c3 --- /dev/null +++ b/graphene_django/tests/test_get_queryset.py @@ -0,0 +1,355 @@ +import pytest + +import graphene +from graphene.relay import Node + +from graphql_relay import to_global_id + +from ..fields import DjangoConnectionField +from ..types import DjangoObjectType + +from .models import Article, Reporter + + +class TestShouldCallGetQuerySetOnForeignKey: + """ + Check that the get_queryset method is called in both forward and reversed direction + of a foreignkey on types. + (see issue #1111) + """ + + @pytest.fixture(autouse=True) + def setup_schema(self): + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + + @classmethod + def get_queryset(cls, queryset, info): + if info.context and info.context.get("admin"): + return queryset + raise Exception("Not authorized to access reporters.") + + class ArticleType(DjangoObjectType): + class Meta: + model = Article + + @classmethod + def get_queryset(cls, queryset, info): + return queryset.exclude(headline__startswith="Draft") + + class Query(graphene.ObjectType): + reporter = graphene.Field(ReporterType, id=graphene.ID(required=True)) + article = graphene.Field(ArticleType, id=graphene.ID(required=True)) + + def resolve_reporter(self, info, id): + return ( + ReporterType.get_queryset(Reporter.objects, info) + .filter(id=id) + .last() + ) + + def resolve_article(self, info, id): + return ( + ArticleType.get_queryset(Article.objects, info).filter(id=id).last() + ) + + self.schema = graphene.Schema(query=Query) + + self.reporter = Reporter.objects.create(first_name="Jane", last_name="Doe") + + self.articles = [ + Article.objects.create( + headline="A fantastic article", + reporter=self.reporter, + editor=self.reporter, + ), + Article.objects.create( + headline="Draft: My next best seller", + reporter=self.reporter, + editor=self.reporter, + ), + ] + + def test_get_queryset_called_on_field(self): + # If a user tries to access an article it is fine as long as it's not a draft one + query = """ + query getArticle($id: ID!) { + article(id: $id) { + headline + } + } + """ + # Non-draft + result = self.schema.execute(query, variables={"id": self.articles[0].id}) + assert not result.errors + assert result.data["article"] == { + "headline": "A fantastic article", + } + # Draft + result = self.schema.execute(query, variables={"id": self.articles[1].id}) + assert not result.errors + assert result.data["article"] is None + + # If a non admin user tries to access a reporter they should get our authorization error + query = """ + query getReporter($id: ID!) { + reporter(id: $id) { + firstName + } + } + """ + + result = self.schema.execute(query, variables={"id": self.reporter.id}) + assert len(result.errors) == 1 + assert result.errors[0].message == "Not authorized to access reporters." + + # An admin user should be able to get reporters + query = """ + query getReporter($id: ID!) { + reporter(id: $id) { + firstName + } + } + """ + + result = self.schema.execute( + query, variables={"id": self.reporter.id}, context_value={"admin": True}, + ) + assert not result.errors + assert result.data == {"reporter": {"firstName": "Jane"}} + + def test_get_queryset_called_on_foreignkey(self): + # If a user tries to access a reporter through an article they should get our authorization error + query = """ + query getArticle($id: ID!) { + article(id: $id) { + headline + reporter { + firstName + } + } + } + """ + + result = self.schema.execute(query, variables={"id": self.articles[0].id}) + assert len(result.errors) == 1 + assert result.errors[0].message == "Not authorized to access reporters." + + # An admin user should be able to get reporters through an article + query = """ + query getArticle($id: ID!) { + article(id: $id) { + headline + reporter { + firstName + } + } + } + """ + + result = self.schema.execute( + query, variables={"id": self.articles[0].id}, context_value={"admin": True}, + ) + assert not result.errors + assert result.data["article"] == { + "headline": "A fantastic article", + "reporter": {"firstName": "Jane"}, + } + + # An admin user should not be able to access draft article through a reporter + query = """ + query getReporter($id: ID!) { + reporter(id: $id) { + firstName + articles { + headline + } + } + } + """ + + result = self.schema.execute( + query, variables={"id": self.reporter.id}, context_value={"admin": True}, + ) + assert not result.errors + assert result.data["reporter"] == { + "firstName": "Jane", + "articles": [{"headline": "A fantastic article"}], + } + + +class TestShouldCallGetQuerySetOnForeignKeyNode: + """ + Check that the get_queryset method is called in both forward and reversed direction + of a foreignkey on types using a node interface. + (see issue #1111) + """ + + @pytest.fixture(autouse=True) + def setup_schema(self): + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + + @classmethod + def get_queryset(cls, queryset, info): + if info.context and info.context.get("admin"): + return queryset + raise Exception("Not authorized to access reporters.") + + class ArticleType(DjangoObjectType): + class Meta: + model = Article + interfaces = (Node,) + + @classmethod + def get_queryset(cls, queryset, info): + return queryset.exclude(headline__startswith="Draft") + + class Query(graphene.ObjectType): + reporter = Node.Field(ReporterType) + article = Node.Field(ArticleType) + + self.schema = graphene.Schema(query=Query) + + self.reporter = Reporter.objects.create(first_name="Jane", last_name="Doe") + + self.articles = [ + Article.objects.create( + headline="A fantastic article", + reporter=self.reporter, + editor=self.reporter, + ), + Article.objects.create( + headline="Draft: My next best seller", + reporter=self.reporter, + editor=self.reporter, + ), + ] + + def test_get_queryset_called_on_node(self): + # If a user tries to access an article it is fine as long as it's not a draft one + query = """ + query getArticle($id: ID!) { + article(id: $id) { + headline + } + } + """ + # Non-draft + result = self.schema.execute( + query, variables={"id": to_global_id("ArticleType", self.articles[0].id)} + ) + assert not result.errors + assert result.data["article"] == { + "headline": "A fantastic article", + } + # Draft + result = self.schema.execute( + query, variables={"id": to_global_id("ArticleType", self.articles[1].id)} + ) + assert not result.errors + assert result.data["article"] is None + + # If a non admin user tries to access a reporter they should get our authorization error + query = """ + query getReporter($id: ID!) { + reporter(id: $id) { + firstName + } + } + """ + + result = self.schema.execute( + query, variables={"id": to_global_id("ReporterType", self.reporter.id)} + ) + assert len(result.errors) == 1 + assert result.errors[0].message == "Not authorized to access reporters." + + # An admin user should be able to get reporters + query = """ + query getReporter($id: ID!) { + reporter(id: $id) { + firstName + } + } + """ + + result = self.schema.execute( + query, + variables={"id": to_global_id("ReporterType", self.reporter.id)}, + context_value={"admin": True}, + ) + assert not result.errors + assert result.data == {"reporter": {"firstName": "Jane"}} + + def test_get_queryset_called_on_foreignkey(self): + # If a user tries to access a reporter through an article they should get our authorization error + query = """ + query getArticle($id: ID!) { + article(id: $id) { + headline + reporter { + firstName + } + } + } + """ + + result = self.schema.execute( + query, variables={"id": to_global_id("ArticleType", self.articles[0].id)} + ) + assert len(result.errors) == 1 + assert result.errors[0].message == "Not authorized to access reporters." + + # An admin user should be able to get reporters through an article + query = """ + query getArticle($id: ID!) { + article(id: $id) { + headline + reporter { + firstName + } + } + } + """ + + result = self.schema.execute( + query, + variables={"id": to_global_id("ArticleType", self.articles[0].id)}, + context_value={"admin": True}, + ) + assert not result.errors + assert result.data["article"] == { + "headline": "A fantastic article", + "reporter": {"firstName": "Jane"}, + } + + # An admin user should not be able to access draft article through a reporter + query = """ + query getReporter($id: ID!) { + reporter(id: $id) { + firstName + articles { + edges { + node { + headline + } + } + } + } + } + """ + + result = self.schema.execute( + query, + variables={"id": to_global_id("ReporterType", self.reporter.id)}, + context_value={"admin": True}, + ) + assert not result.errors + assert result.data["reporter"] == { + "firstName": "Jane", + "articles": {"edges": [{"node": {"headline": "A fantastic article"}}]}, + } diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 207c211..e6ae64f 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -15,7 +15,7 @@ from ..compat import IntegerRangeField, MissingType from ..fields import DjangoConnectionField from ..types import DjangoObjectType from ..utils import DJANGO_FILTER_INSTALLED -from .models import Article, CNNReporter, Film, FilmDetails, Reporter +from .models import Article, CNNReporter, Film, FilmDetails, Person, Pet, Reporter def test_should_query_only_fields(): @@ -251,8 +251,8 @@ def test_should_node(): def test_should_query_onetoone_fields(): - film = Film(id=1) - film_details = FilmDetails(id=1, film=film) + film = Film.objects.create(id=1) + film_details = FilmDetails.objects.create(id=1, film=film) class FilmNode(DjangoObjectType): class Meta: @@ -1697,3 +1697,67 @@ def test_connection_should_succeed_if_last_higher_than_number_of_objects(): } } assert result.data == expected + + +def test_should_query_nullable_foreign_key(): + class PetType(DjangoObjectType): + class Meta: + model = Pet + + class PersonType(DjangoObjectType): + class Meta: + model = Person + + class Query(graphene.ObjectType): + pet = graphene.Field(PetType, name=graphene.String(required=True)) + person = graphene.Field(PersonType, name=graphene.String(required=True)) + + def resolve_pet(self, info, name): + return Pet.objects.filter(name=name).first() + + def resolve_person(self, info, name): + return Person.objects.filter(name=name).first() + + schema = graphene.Schema(query=Query) + + person = Person.objects.create(name="Jane") + pets = [ + Pet.objects.create(name="Stray dog", age=1), + Pet.objects.create(name="Jane's dog", owner=person, age=1), + ] + + query_pet = """ + query getPet($name: String!) { + pet(name: $name) { + owner { + name + } + } + } + """ + result = schema.execute(query_pet, variables={"name": "Stray dog"}) + assert not result.errors + assert result.data["pet"] == { + "owner": None, + } + + result = schema.execute(query_pet, variables={"name": "Jane's dog"}) + assert not result.errors + assert result.data["pet"] == { + "owner": {"name": "Jane"}, + } + + query_owner = """ + query getOwner($name: String!) { + person(name: $name) { + pets { + name + } + } + } + """ + result = schema.execute(query_owner, variables={"name": "Jane"}) + assert not result.errors + assert result.data["person"] == { + "pets": [{"name": "Jane's dog"}], + } diff --git a/setup.py b/setup.py index d9aefef..3a46d24 100644 --- a/setup.py +++ b/setup.py @@ -59,7 +59,8 @@ setup( keywords="api graphql protocol rest relay graphene", packages=find_packages(exclude=["tests", "examples", "examples.*"]), install_requires=[ - "graphene>=3.0,<4", + # "graphene>=3.0,<4", + "graphene @ git+https://github.com/loft-orbital/graphene.git@loft-v3-1.0#egg=graphene", "graphql-core>=3.1.0,<4", "graphql-relay>=3.1.1,<4", "Django>=3.2", From 0f40da7b317e474bbb0d777e4e96599f3b989903 Mon Sep 17 00:00:00 2001 From: Semyon Pupkov Date: Fri, 23 Sep 2022 13:47:10 +0500 Subject: [PATCH 45/52] Make errors in form mutation non nullable (#1286) --- graphene_django/forms/mutation.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/forms/mutation.py b/graphene_django/forms/mutation.py index 5a3d8e7..13e9863 100644 --- a/graphene_django/forms/mutation.py +++ b/graphene_django/forms/mutation.py @@ -117,7 +117,7 @@ class DjangoModelFormMutation(BaseDjangoFormMutation): class Meta: abstract = True - errors = graphene.List(ErrorType) + errors = graphene.List(graphene.NonNull(ErrorType), required=True) @classmethod def __init_subclass_with_meta__( From 541caa117eedf74923c2ed396afeee5fd568850e Mon Sep 17 00:00:00 2001 From: Firas K <3097061+firaskafri@users.noreply.github.com> Date: Sat, 24 Sep 2022 15:50:40 +0300 Subject: [PATCH 46/52] Fixes related to pr#1412 (#1352) * fix: setup.py graphene dependency * fix: graphene_django/tests/test_get_queryset.py format Co-authored-by: Firas Kafri --- graphene_django/tests/test_get_queryset.py | 12 +++++++++--- setup.py | 3 +-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/graphene_django/tests/test_get_queryset.py b/graphene_django/tests/test_get_queryset.py index b2647c3..91bdc70 100644 --- a/graphene_django/tests/test_get_queryset.py +++ b/graphene_django/tests/test_get_queryset.py @@ -114,7 +114,9 @@ class TestShouldCallGetQuerySetOnForeignKey: """ result = self.schema.execute( - query, variables={"id": self.reporter.id}, context_value={"admin": True}, + query, + variables={"id": self.reporter.id}, + context_value={"admin": True}, ) assert not result.errors assert result.data == {"reporter": {"firstName": "Jane"}} @@ -149,7 +151,9 @@ class TestShouldCallGetQuerySetOnForeignKey: """ result = self.schema.execute( - query, variables={"id": self.articles[0].id}, context_value={"admin": True}, + query, + variables={"id": self.articles[0].id}, + context_value={"admin": True}, ) assert not result.errors assert result.data["article"] == { @@ -170,7 +174,9 @@ class TestShouldCallGetQuerySetOnForeignKey: """ result = self.schema.execute( - query, variables={"id": self.reporter.id}, context_value={"admin": True}, + query, + variables={"id": self.reporter.id}, + context_value={"admin": True}, ) assert not result.errors assert result.data["reporter"] == { diff --git a/setup.py b/setup.py index 3a46d24..d9aefef 100644 --- a/setup.py +++ b/setup.py @@ -59,8 +59,7 @@ setup( keywords="api graphql protocol rest relay graphene", packages=find_packages(exclude=["tests", "examples", "examples.*"]), install_requires=[ - # "graphene>=3.0,<4", - "graphene @ git+https://github.com/loft-orbital/graphene.git@loft-v3-1.0#egg=graphene", + "graphene>=3.0,<4", "graphql-core>=3.1.0,<4", "graphql-relay>=3.1.1,<4", "Django>=3.2", From 05d3df92e7be6e1c547f2cb1b3b55ff13339f713 Mon Sep 17 00:00:00 2001 From: Craig <41215134+c-py@users.noreply.github.com> Date: Sat, 24 Sep 2022 22:59:53 +1000 Subject: [PATCH 47/52] Delay assignment of csrftoken (#1289) --- .../static/graphene_django/graphiql.js | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/graphene_django/static/graphene_django/graphiql.js b/graphene_django/static/graphene_django/graphiql.js index ac010e8..f457f65 100644 --- a/graphene_django/static/graphene_django/graphiql.js +++ b/graphene_django/static/graphene_django/graphiql.js @@ -10,14 +10,6 @@ history, location, ) { - // Parse the cookie value for a CSRF token - var csrftoken; - var cookies = ("; " + document.cookie).split("; csrftoken="); - if (cookies.length == 2) { - csrftoken = cookies.pop().split(";").shift(); - } else { - csrftoken = document.querySelector("[name=csrfmiddlewaretoken]").value; - } // Collect the URL parameters var parameters = {}; @@ -68,9 +60,19 @@ var headers = opts.headers || {}; headers['Accept'] = headers['Accept'] || 'application/json'; headers['Content-Type'] = headers['Content-Type'] || 'application/json'; + + // Parse the cookie value for a CSRF token + var csrftoken; + var cookies = ("; " + document.cookie).split("; csrftoken="); + if (cookies.length == 2) { + csrftoken = cookies.pop().split(";").shift(); + } else { + csrftoken = document.querySelector("[name=csrfmiddlewaretoken]").value; + } if (csrftoken) { headers['X-CSRFToken'] = csrftoken } + return fetch(fetchURL, { method: "post", headers: headers, From 60b30320146313f25d072394252990f2e8115cde Mon Sep 17 00:00:00 2001 From: belkka Date: Sat, 24 Sep 2022 16:00:12 +0300 Subject: [PATCH 48/52] Fix type hint for DjangoObjectTypeOptions.model (#1269) Proper type is `typing.Type[Model]`, not `Model` --- graphene_django/types.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/types.py b/graphene_django/types.py index c256f1d..0ebb7d3 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -122,7 +122,7 @@ def validate_fields(type_, model, fields, only_fields, exclude_fields): class DjangoObjectTypeOptions(ObjectTypeOptions): - model = None # type: Model + model = None # type: Type[Model] registry = None # type: Registry connection = None # type: Type[Connection] From 97442f9ceeb1061d24e022bd7044a63d4230d53b Mon Sep 17 00:00:00 2001 From: belkka Date: Sat, 24 Sep 2022 16:00:22 +0300 Subject: [PATCH 49/52] Fix code examples in queries.rst (#1265) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix code examples in queries.rst Code example in Arguments section doesn't work as stated in its comment — if "foo" or "bar" are not declare in the graphql query, it will be an error, not they become None. Code example in Info section has invalid indentation, `resolve_questions()` seems to be a `Query` method, but it's indented as module-level function. * Fix indentation in query examples * Enable syntax highlight for graphql queries --- docs/queries.rst | 40 ++++++++++++++++++++-------------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/docs/queries.rst b/docs/queries.rst index 1e1ba82..8b85d45 100644 --- a/docs/queries.rst +++ b/docs/queries.rst @@ -151,7 +151,7 @@ For example the following ``Model`` and ``DjangoObjectType``: Results in the following GraphQL schema definition: -.. code:: +.. code:: graphql type Pet { id: ID! @@ -178,7 +178,7 @@ You can disable this automatic conversion by setting fields = ("id", "kind",) convert_choices_to_enum = False -.. code:: +.. code:: graphql type Pet { id: ID! @@ -313,7 +313,7 @@ Additionally, Resolvers will receive **any arguments declared in the field defin bar=graphene.Int() ) - def resolve_question(root, info, foo, bar): + def resolve_question(root, info, foo=None, bar=None): # 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() @@ -336,12 +336,12 @@ of Django's ``HTTPRequest`` in your resolve methods, such as checking for authen 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() - else: - return Question.objects.none() + def resolve_questions(root, info): + # See if a user is authenticated + if info.context.user.is_authenticated(): + return Question.objects.all() + else: + return Question.objects.none() DjangoObjectTypes @@ -418,29 +418,29 @@ the core graphene pages for more information on customizing the Relay experience You can now execute queries like: -.. code:: python +.. code:: graphql { questions (first: 2, after: "YXJyYXljb25uZWN0aW9uOjEwNQ==") { pageInfo { - startCursor - endCursor - hasNextPage - hasPreviousPage + startCursor + endCursor + hasNextPage + hasPreviousPage } edges { - cursor - node { - id - question_text - } + cursor + node { + id + question_text + } } } } Which returns: -.. code:: python +.. code:: json { "data": { From 0b2cc4ecb2c8ad4a12c21ab1384d2ce0c89295cb Mon Sep 17 00:00:00 2001 From: Forest Anderson Date: Sat, 24 Sep 2022 09:00:45 -0400 Subject: [PATCH 50/52] Fixed deprecation warning (#1313) --- graphene_django/fields.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 3c48595..05a7010 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -2,7 +2,7 @@ from functools import partial from django.db.models.query import QuerySet -from graphql_relay.connection.array_connection import ( +from graphql_relay import ( connection_from_array_slice, cursor_to_offset, get_offset_with_default, From 9a60589732411b1b11a190114bc2a412c02d5fe2 Mon Sep 17 00:00:00 2001 From: Gabriel Lacroix Date: Sat, 24 Sep 2022 09:02:33 -0400 Subject: [PATCH 51/52] Make instructions runnable without tweaking (#1224) Introduces two changes to make sure the instructions in the tutorial don't require debugging: - Add `cd ..` when first syncing the database so that `manage.py` is accessible in the working directory. - Change `cookbook.ingredients.apps.IngredientsConfig.name` to `cookbook.ingredients` from `ingredients` to prevent the following exception: ```python django.core.exceptions.ImproperlyConfigured: Cannot import 'ingredients'. Check that 'cookbook.ingredients.apps.IngredientsConfig.name' is correct. ``` --- docs/tutorial-plain.rst | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/tutorial-plain.rst b/docs/tutorial-plain.rst index 45927a5..43b6da9 100644 --- a/docs/tutorial-plain.rst +++ b/docs/tutorial-plain.rst @@ -35,6 +35,7 @@ Now sync your database for the first time: .. code:: bash + cd .. python manage.py migrate Let's create a few simple models... @@ -77,6 +78,18 @@ Add ingredients as INSTALLED_APPS: "cookbook.ingredients", ] +Make sure the app name in ``cookbook.ingredients.apps.IngredientsConfig`` is set to ``cookbook.ingredients``. + +.. code:: python + + # cookbook/ingredients/apps.py + + from django.apps import AppConfig + + + class IngredientsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'cookbook.ingredients' Don't forget to create & run migrations: From 07940aa5f537bb5e38af3bd38bc1b770bf678cd1 Mon Sep 17 00:00:00 2001 From: Alan Rivas Date: Sat, 24 Sep 2022 10:03:45 -0300 Subject: [PATCH 52/52] Update tutorial-relay.rst (#1220)