From dab6080fcf36b521107d19c5b55a35b6487da63c Mon Sep 17 00:00:00 2001 From: Rustam Ganeyev Date: Tue, 29 Dec 2020 19:30:10 +0000 Subject: [PATCH 01/21] Fixed typo in documentation (#1078) Added missing kwargs to documentation --- docs/queries.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/queries.rst b/docs/queries.rst index 02a2bf2..d2da781 100644 --- a/docs/queries.rst +++ b/docs/queries.rst @@ -287,7 +287,7 @@ Where "foo" is the name of the field declared in the ``Query`` object. class Query(graphene.ObjectType): foo = graphene.List(QuestionType) - def resolve_foo(root, info): + def resolve_foo(root, info, **kwargs): id = kwargs.get("id") return Question.objects.get(id) From e559a42374529378dbdcb6bcb4f3ff704312a66b Mon Sep 17 00:00:00 2001 From: Tim Gates Date: Wed, 30 Dec 2020 06:30:30 +1100 Subject: [PATCH 02/21] docs: fix simple typo, outputing -> outputting (#1077) There is a small typo in docs/debug.rst. Should read `outputting` rather than `outputing`. --- docs/debug.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/debug.rst b/docs/debug.rst index d1cbb21..2286519 100644 --- a/docs/debug.rst +++ b/docs/debug.rst @@ -3,7 +3,7 @@ Django Debug Middleware You can debug your GraphQL queries in a similar way to `django-debug-toolbar `__, -but outputing in the results in GraphQL response as fields, instead of +but outputting in the results in GraphQL response as fields, instead of the graphical HTML interface. For that, you will need to add the plugin in your graphene schema. @@ -43,7 +43,7 @@ And in your ``settings.py``: Querying -------- -You can query it for outputing all the sql transactions that happened in +You can query it for outputting all the sql transactions that happened in the GraphQL request, like: .. code:: From 2d0b9ddd426810678e2dbaa28be9dbe55f919bf2 Mon Sep 17 00:00:00 2001 From: Jason Kraus Date: Wed, 30 Dec 2020 08:25:41 -0800 Subject: [PATCH 03/21] improvement: convert decimal field to graphene decimal (#1083) --- graphene_django/converter.py | 5 +++++ graphene_django/tests/test_converter.py | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 0de6964..63cc35d 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -18,6 +18,7 @@ from graphene import ( DateTime, Date, Time, + Decimal, ) from graphene.types.json import JSONString from graphene.utils.str_converters import to_camel_case @@ -160,6 +161,10 @@ 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) + + @convert_django_field.register(models.FloatField) @convert_django_field.register(models.DurationField) def convert_field_to_float(field, registry=None): diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index 7d8e669..287ec82 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -242,6 +242,10 @@ def test_should_float_convert_float(): assert_conversion(models.FloatField, graphene.Float) +def test_should_float_convert_decimal(): + assert_conversion(models.DecimalField, graphene.Decimal) + + def test_should_manytomany_convert_connectionorlist(): registry = Registry() dynamic_field = convert_django_field(Reporter._meta.local_many_to_many[0], registry) From 8c4851609355831af66a71ce178bffc98356e269 Mon Sep 17 00:00:00 2001 From: Thiago Bellini Ribeiro Date: Thu, 31 Dec 2020 02:03:57 -0300 Subject: [PATCH 04/21] Also convert BaseCSVFilter for custom fields (#1081) --- .../filter/tests/test_in_filter.py | 50 ++++++++++++++++++- graphene_django/filter/utils.py | 16 +++--- graphene_django/tests/models.py | 4 ++ 3 files changed, 60 insertions(+), 10 deletions(-) diff --git a/graphene_django/filter/tests/test_in_filter.py b/graphene_django/filter/tests/test_in_filter.py index 3d4034e..7bbee65 100644 --- a/graphene_django/filter/tests/test_in_filter.py +++ b/graphene_django/filter/tests/test_in_filter.py @@ -1,9 +1,11 @@ import pytest +from django_filters import FilterSet +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 +from graphene_django.tests.models import Pet, Person from graphene_django.utils import DJANGO_FILTER_INSTALLED pytestmark = [] @@ -28,8 +30,27 @@ class PetNode(DjangoObjectType): } +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(): @@ -61,6 +82,33 @@ 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.""" + 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(): """ Test in filter on an integer field. diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index becd5f5..71c5b49 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -19,6 +19,7 @@ def get_filtering_args_from_filterset(filterset_class, type): model = filterset_class._meta.model for name, filter_field in six.iteritems(filterset_class.base_filters): form_field = None + filter_type = filter_field.lookup_expr if name in filterset_class.declared_filters: # Get the filter field from the explicitly declared filter @@ -27,7 +28,6 @@ def get_filtering_args_from_filterset(filterset_class, type): else: # Get the filter field with no explicit type declaration model_field = get_model_field(model, filter_field.field_name) - filter_type = filter_field.lookup_expr if filter_type != "isnull" and hasattr(model_field, "formfield"): form_field = model_field.formfield( required=filter_field.extra.get("required", False) @@ -40,10 +40,11 @@ def get_filtering_args_from_filterset(filterset_class, type): field = convert_form_field(form_field) - if filter_type in ["in", "range"]: - # Replace CSV 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 = List(field.get_type()) + if filter_type in ["in", "range"]: + # Replace CSV 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 = List(field.get_type()) field_type = field.Argument() field_type.description = filter_field.label @@ -79,10 +80,7 @@ def replace_csv_filters(filterset_class): """ for name, filter_field in six.iteritems(filterset_class.base_filters): filter_type = filter_field.lookup_expr - if ( - filter_type in ["in", "range"] - and name not in filterset_class.declared_filters - ): + if filter_type in ["in", "range"]: assert isinstance(filter_field, BaseCSVFilter) filterset_class.base_filters[name] = Filter( field_name=filter_field.field_name, diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py index 44a5d8a..20f509c 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -6,6 +6,10 @@ from django.utils.translation import ugettext_lazy as _ CHOICES = ((1, "this"), (2, _("that"))) +class Person(models.Model): + name = models.CharField(max_length=30) + + class Pet(models.Model): name = models.CharField(max_length=30) age = models.PositiveIntegerField() From 40e525293626f760a95ed0f2cc37f78048502728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Thu, 31 Dec 2020 08:12:24 +0300 Subject: [PATCH 05/21] Use the Django TestCase's Client (#1084) * Use the Django Client test utility instance that Django provides with its TestCase class. This allows GraphQL tests to make use of the stateful client methods like login() * Add missing test case initializer call * Don't break backward compability * Add test for pending deprecation warning on GraphQLTestCase._client Co-authored-by: Tom Nightingale --- graphene_django/tests/test_utils.py | 2 ++ graphene_django/utils/testing.py | 20 ++++++++++------- graphene_django/utils/tests/test_testing.py | 24 +++++++++++++++++++++ 3 files changed, 38 insertions(+), 8 deletions(-) create mode 100644 graphene_django/utils/tests/test_testing.py diff --git a/graphene_django/tests/test_utils.py b/graphene_django/tests/test_utils.py index f5a8b05..e7aa027 100644 --- a/graphene_django/tests/test_utils.py +++ b/graphene_django/tests/test_utils.py @@ -51,7 +51,9 @@ def test_graphql_test_case_op_name(post_mock): pass tc = TestClass() + tc._pre_setup() tc.setUpClass() + tc.query("query { }", op_name="QueryName") body = json.loads(post_mock.call_args.args[1]) # `operationName` field from https://graphql.org/learn/serving-over-http/#post-request diff --git a/graphene_django/utils/testing.py b/graphene_django/utils/testing.py index 871c440..b758ac8 100644 --- a/graphene_django/utils/testing.py +++ b/graphene_django/utils/testing.py @@ -1,6 +1,7 @@ import json +import warnings -from django.test import TestCase, Client +from django.test import Client, TestCase DEFAULT_GRAPHQL_URL = "/graphql/" @@ -68,12 +69,6 @@ class GraphQLTestCase(TestCase): # URL to graphql endpoint GRAPHQL_URL = DEFAULT_GRAPHQL_URL - @classmethod - def setUpClass(cls): - super(GraphQLTestCase, cls).setUpClass() - - cls._client = Client() - def query(self, query, op_name=None, input_data=None, variables=None, headers=None): """ Args: @@ -99,10 +94,19 @@ class GraphQLTestCase(TestCase): input_data=input_data, variables=variables, headers=headers, - client=self._client, + client=self.client, graphql_url=self.GRAPHQL_URL, ) + @property + def _client(self): + warnings.warn( + "Using `_client` is deprecated in favour of `client`.", + PendingDeprecationWarning, + stacklevel=2, + ) + return self.client + def assertResponseNoErrors(self, resp, msg=None): """ Assert that the call went through correctly. 200 means the syntax is ok, if there are no `errors`, diff --git a/graphene_django/utils/tests/test_testing.py b/graphene_django/utils/tests/test_testing.py new file mode 100644 index 0000000..df78321 --- /dev/null +++ b/graphene_django/utils/tests/test_testing.py @@ -0,0 +1,24 @@ +import pytest + +from .. import GraphQLTestCase +from ...tests.test_types import with_local_registry + + +@with_local_registry +def test_graphql_test_case_deprecated_client(): + """ + Test that `GraphQLTestCase._client`'s should raise pending deprecation warning. + """ + + class TestClass(GraphQLTestCase): + GRAPHQL_SCHEMA = True + + def runTest(self): + pass + + tc = TestClass() + tc._pre_setup() + tc.setUpClass() + + with pytest.warns(PendingDeprecationWarning): + tc._client From caf954861025b9f3d9d3f9c204a7cbbc87352265 Mon Sep 17 00:00:00 2001 From: Jason Kraus Date: Wed, 30 Dec 2020 22:46:51 -0800 Subject: [PATCH 06/21] v2.15.0 --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 792f7be..7472a06 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,7 +1,7 @@ from .fields import DjangoConnectionField, DjangoListField from .types import DjangoObjectType -__version__ = "2.14.0" +__version__ = "2.15.0" __all__ = [ "__version__", From 1281c1338df27df529ff7f891d2f23f2993a3178 Mon Sep 17 00:00:00 2001 From: Rustam Ganeyev Date: Sat, 2 Jan 2021 04:36:50 +0000 Subject: [PATCH 07/21] Introduced optional_fields to SerializationMutation (#1080) * added optional_field to SerializationMutation to forcefully mark some fields as optional * added tests --- graphene_django/rest_framework/mutation.py | 10 ++++++++- .../rest_framework/serializer_converter.py | 9 ++++++-- .../rest_framework/tests/test_mutation.py | 21 ++++++++++++++++++- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py index 000b21e..9e2ae12 100644 --- a/graphene_django/rest_framework/mutation.py +++ b/graphene_django/rest_framework/mutation.py @@ -18,6 +18,7 @@ class SerializerMutationOptions(MutationOptions): model_class = None model_operations = ["create", "update"] serializer_class = None + optional_fields = () def fields_for_serializer( @@ -27,6 +28,7 @@ def fields_for_serializer( is_input=False, convert_choices_to_enum=True, lookup_field=None, + optional_fields=(), ): fields = OrderedDict() for name, field in serializer.fields.items(): @@ -44,9 +46,13 @@ def fields_for_serializer( if is_not_in_only or is_excluded: continue + is_optional = name in optional_fields fields[name] = convert_serializer_field( - field, is_input=is_input, convert_choices_to_enum=convert_choices_to_enum + field, + is_input=is_input, + convert_choices_to_enum=convert_choices_to_enum, + force_optional=is_optional, ) return fields @@ -70,6 +76,7 @@ class SerializerMutation(ClientIDMutation): exclude_fields=(), convert_choices_to_enum=True, _meta=None, + optional_fields=(), **options ): @@ -95,6 +102,7 @@ class SerializerMutation(ClientIDMutation): is_input=True, convert_choices_to_enum=convert_choices_to_enum, lookup_field=lookup_field, + optional_fields=optional_fields, ) output_fields = fields_for_serializer( serializer, diff --git a/graphene_django/rest_framework/serializer_converter.py b/graphene_django/rest_framework/serializer_converter.py index 82a113a..18f34b8 100644 --- a/graphene_django/rest_framework/serializer_converter.py +++ b/graphene_django/rest_framework/serializer_converter.py @@ -19,7 +19,9 @@ def get_graphene_type_from_serializer_field(field): ) -def convert_serializer_field(field, is_input=True, convert_choices_to_enum=True): +def convert_serializer_field( + field, is_input=True, convert_choices_to_enum=True, force_optional=False +): """ Converts a django rest frameworks field to a graphql field and marks the field as required if we are creating an input type @@ -32,7 +34,10 @@ def convert_serializer_field(field, is_input=True, convert_choices_to_enum=True) graphql_type = get_graphene_type_from_serializer_field(field) args = [] - kwargs = {"description": field.help_text, "required": is_input and field.required} + kwargs = { + "description": field.help_text, + "required": is_input and field.required and not force_optional, + } # if it is a tuple or a list it means that we are returning # the graphql type and the child type diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py index ffbc4b5..5c2518d 100644 --- a/graphene_django/rest_framework/tests/test_mutation.py +++ b/graphene_django/rest_framework/tests/test_mutation.py @@ -3,7 +3,7 @@ import datetime from py.test import raises from rest_framework import serializers -from graphene import Field, ResolveInfo +from graphene import Field, ResolveInfo, NonNull, String from graphene.types.inputobjecttype import InputObjectType from ...types import DjangoObjectType @@ -98,6 +98,25 @@ def test_exclude_fields(): assert "created" not in MyMutation.Input._meta.fields +def test_model_serializer_required_fields(): + class MyMutation(SerializerMutation): + class Meta: + serializer_class = MyModelSerializer + + assert "cool_name" in MyMutation.Input._meta.fields + assert MyMutation.Input._meta.fields["cool_name"].type == NonNull(String) + + +def test_model_serializer_optional_fields(): + class MyMutation(SerializerMutation): + class Meta: + serializer_class = MyModelSerializer + optional_fields = ("cool_name",) + + assert "cool_name" in MyMutation.Input._meta.fields + assert MyMutation.Input._meta.fields["cool_name"].type == String + + def test_write_only_field(): class WriteOnlyFieldModelSerializer(serializers.ModelSerializer): password = serializers.CharField(write_only=True) From aff56b882b55bc20a3896b98aa77868a0e01b6a5 Mon Sep 17 00:00:00 2001 From: Thomas Leonard <64223923+tcleonard@users.noreply.github.com> Date: Sun, 10 Jan 2021 04:15:56 +0100 Subject: [PATCH 08/21] Validate in and range filter inputs (#1092) Co-authored-by: Thomas Leonard --- graphene_django/filter/__init__.py | 2 +- graphene_django/filter/filters.py | 75 ++++++++++++ graphene_django/filter/filterset.py | 25 +--- .../filter/tests/test_in_filter.py | 12 +- .../filter/tests/test_range_filter.py | 115 ++++++++++++++++++ graphene_django/filter/utils.py | 16 ++- 6 files changed, 211 insertions(+), 34 deletions(-) create mode 100644 graphene_django/filter/filters.py create mode 100644 graphene_django/filter/tests/test_range_filter.py diff --git a/graphene_django/filter/__init__.py b/graphene_django/filter/__init__.py index daafe56..5de36ad 100644 --- a/graphene_django/filter/__init__.py +++ b/graphene_django/filter/__init__.py @@ -9,7 +9,7 @@ if not DJANGO_FILTER_INSTALLED: ) else: from .fields import DjangoFilterConnectionField - from .filterset import GlobalIDFilter, GlobalIDMultipleChoiceFilter + from .filters import GlobalIDFilter, GlobalIDMultipleChoiceFilter __all__ = [ "DjangoFilterConnectionField", diff --git a/graphene_django/filter/filters.py b/graphene_django/filter/filters.py new file mode 100644 index 0000000..44832b5 --- /dev/null +++ b/graphene_django/filter/filters.py @@ -0,0 +1,75 @@ +from django.core.exceptions import ValidationError +from django.forms import Field + +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) + + +class InFilter(Filter): + """ + Filter for a list of value using the `__in` Django filter. + """ + + def filter(self, qs, value): + """ + Override the default filter class to check first weather 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(InFilter, self).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 diff --git a/graphene_django/filter/filterset.py b/graphene_django/filter/filterset.py index 7676ea8..0fd0a82 100644 --- a/graphene_django/filter/filterset.py +++ b/graphene_django/filter/filterset.py @@ -1,32 +1,11 @@ import itertools from django.db import models -from django_filters import Filter, MultipleChoiceFilter, VERSION +from django_filters import VERSION from django_filters.filterset import BaseFilterSet, FilterSet from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS -from graphql_relay.node.node import from_global_id - -from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField - - -class GlobalIDFilter(Filter): - 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) +from .filters import GlobalIDFilter, GlobalIDMultipleChoiceFilter GRAPHENE_FILTER_SET_OVERRIDES = { diff --git a/graphene_django/filter/tests/test_in_filter.py b/graphene_django/filter/tests/test_in_filter.py index 7bbee65..9e9c323 100644 --- a/graphene_django/filter/tests/test_in_filter.py +++ b/graphene_django/filter/tests/test_in_filter.py @@ -157,20 +157,19 @@ def test_int_in_filter(): ] -def test_int_range_filter(): +def test_in_filter_with_empty_list(): """ - Test in filter on an integer field. + Check that using a in filter with an empty list provided as input returns no objects. """ Pet.objects.create(name="Brutus", age=12) Pet.objects.create(name="Mimi", age=8) - Pet.objects.create(name="Jojo, the rabbit", age=3) Pet.objects.create(name="Picotin", age=5) schema = Schema(query=Query) query = """ query { - pets (age_Range: [4, 9]) { + pets (name_In: []) { edges { node { name @@ -181,7 +180,4 @@ def test_int_range_filter(): """ result = schema.execute(query) assert not result.errors - assert result.data["pets"]["edges"] == [ - {"node": {"name": "Mimi"}}, - {"node": {"name": "Picotin"}}, - ] + assert len(result.data["pets"]["edges"]) == 0 diff --git a/graphene_django/filter/tests/test_range_filter.py b/graphene_django/filter/tests/test_range_filter.py new file mode 100644 index 0000000..4d8db4f --- /dev/null +++ b/graphene_django/filter/tests/test_range_filter.py @@ -0,0 +1,115 @@ +import ast +import json +import pytest + +from django_filters import FilterSet +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 +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" + ) + ) + + +class PetNode(DjangoObjectType): + class Meta: + model = Pet + interfaces = (Node,) + filter_fields = { + "name": ["exact", "in"], + "age": ["exact", "in", "range"], + } + + +class Query(ObjectType): + pets = DjangoFilterConnectionField(PetNode) + + +def test_int_range_filter(): + """ + Test range filter on an integer field. + """ + Pet.objects.create(name="Brutus", age=12) + Pet.objects.create(name="Mimi", age=8) + Pet.objects.create(name="Jojo, the rabbit", age=3) + Pet.objects.create(name="Picotin", age=5) + + schema = Schema(query=Query) + + query = """ + query { + pets (age_Range: [4, 9]) { + edges { + node { + name + } + } + } + } + """ + result = schema.execute(query) + assert not result.errors + assert result.data["pets"]["edges"] == [ + {"node": {"name": "Mimi"}}, + {"node": {"name": "Picotin"}}, + ] + + +def test_range_filter_with_invalid_input(): + """ + Test range filter used with invalid inputs raise an error. + """ + Pet.objects.create(name="Brutus", age=12) + Pet.objects.create(name="Mimi", age=8) + Pet.objects.create(name="Jojo, the rabbit", age=3) + Pet.objects.create(name="Picotin", age=5) + + schema = Schema(query=Query) + + query = """ + query ($rangeValue: [Int]) { + pets (age_Range: $rangeValue) { + edges { + node { + name + } + } + } + } + """ + expected_error = json.dumps( + { + "age__range": [ + { + "message": "Invalid range specified: it needs to contain 2 values.", + "code": "invalid", + } + ] + } + ) + + # Empty list + result = schema.execute(query, variables={"rangeValue": []}) + assert len(result.errors) == 1 + assert ast.literal_eval(result.errors[0].message)[0] == expected_error + + # Only one item in the list + result = schema.execute(query, variables={"rangeValue": [1]}) + assert len(result.errors) == 1 + assert ast.literal_eval(result.errors[0].message)[0] == expected_error + + # More than 2 items in the list + result = schema.execute(query, variables={"rangeValue": [1, 2, 3]}) + assert len(result.errors) == 1 + assert ast.literal_eval(result.errors[0].message)[0] == expected_error diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index 71c5b49..dce08c7 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -6,6 +6,7 @@ from django_filters.utils import get_model_field from django_filters.filters import Filter, BaseCSVFilter from .filterset import custom_filterset_factory, setup_filterset +from .filters import InFilter, RangeFilter def get_filtering_args_from_filterset(filterset_class, type): @@ -80,9 +81,20 @@ def replace_csv_filters(filterset_class): """ for name, filter_field in six.iteritems(filterset_class.base_filters): filter_type = filter_field.lookup_expr - if filter_type in ["in", "range"]: + if filter_type == "in": assert isinstance(filter_field, BaseCSVFilter) - filterset_class.base_filters[name] = Filter( + filterset_class.base_filters[name] = InFilter( + field_name=filter_field.field_name, + lookup_expr=filter_field.lookup_expr, + label=filter_field.label, + method=filter_field.method, + exclude=filter_field.exclude, + **filter_field.extra + ) + + if filter_type == "range": + assert isinstance(filter_field, BaseCSVFilter) + filterset_class.base_filters[name] = RangeFilter( field_name=filter_field.field_name, lookup_expr=filter_field.lookup_expr, label=filter_field.label, From e24675e5b7ebba7528760638aa819b23ae20bf43 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Sun, 10 Jan 2021 06:16:18 +0300 Subject: [PATCH 09/21] Fix backward compability on GraphQLTestCase._client setter (#1093) --- graphene_django/utils/testing.py | 13 +++++++++++ graphene_django/utils/tests/test_testing.py | 25 +++++++++++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/graphene_django/utils/testing.py b/graphene_django/utils/testing.py index b758ac8..afe83c2 100644 --- a/graphene_django/utils/testing.py +++ b/graphene_django/utils/testing.py @@ -99,6 +99,10 @@ class GraphQLTestCase(TestCase): ) @property + def _client(self): + pass + + @_client.getter def _client(self): warnings.warn( "Using `_client` is deprecated in favour of `client`.", @@ -107,6 +111,15 @@ class GraphQLTestCase(TestCase): ) return self.client + @_client.setter + def _client(self, client): + warnings.warn( + "Using `_client` is deprecated in favour of `client`.", + PendingDeprecationWarning, + stacklevel=2, + ) + self.client = client + def assertResponseNoErrors(self, resp, msg=None): """ Assert that the call went through correctly. 200 means the syntax is ok, if there are no `errors`, diff --git a/graphene_django/utils/tests/test_testing.py b/graphene_django/utils/tests/test_testing.py index df78321..2ef78f9 100644 --- a/graphene_django/utils/tests/test_testing.py +++ b/graphene_django/utils/tests/test_testing.py @@ -2,12 +2,13 @@ import pytest from .. import GraphQLTestCase from ...tests.test_types import with_local_registry +from django.test import Client @with_local_registry -def test_graphql_test_case_deprecated_client(): +def test_graphql_test_case_deprecated_client_getter(): """ - Test that `GraphQLTestCase._client`'s should raise pending deprecation warning. + `GraphQLTestCase._client`' getter should raise pending deprecation warning. """ class TestClass(GraphQLTestCase): @@ -22,3 +23,23 @@ def test_graphql_test_case_deprecated_client(): with pytest.warns(PendingDeprecationWarning): tc._client + + +@with_local_registry +def test_graphql_test_case_deprecated_client_setter(): + """ + `GraphQLTestCase._client`' setter should raise pending deprecation warning. + """ + + class TestClass(GraphQLTestCase): + GRAPHQL_SCHEMA = True + + def runTest(self): + pass + + tc = TestClass() + tc._pre_setup() + tc.setUpClass() + + with pytest.warns(PendingDeprecationWarning): + tc._client = Client() From 66c890104153ef06dff870ae2b6d43711623c381 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Sun, 10 Jan 2021 09:00:11 +0300 Subject: [PATCH 10/21] Convert Django form / DRF decimals to Graphene decimals (#958) * Convert Django form decimals to Graphene decimals * Ugly fix for test_should_query_filter_node_limit * Convert DRF serializer decimal to Graphene decimal --- graphene_django/filter/tests/test_fields.py | 6 +++--- graphene_django/forms/converter.py | 19 +++++++++++++++++-- graphene_django/forms/tests/test_converter.py | 16 ++++++++-------- .../rest_framework/serializer_converter.py | 6 +++++- .../tests/test_field_converter.py | 4 ++-- graphene_django/tests/test_converter.py | 2 +- 6 files changed, 36 insertions(+), 17 deletions(-) diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index 18e7f0c..6de8361 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 @@ -388,7 +388,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" @@ -671,7 +671,7 @@ def test_should_query_filter_node_limit(): schema = Schema(query=Query) query = """ query NodeFilteringQuery { - allReporters(limit: 1) { + allReporters(limit: "1") { edges { node { id diff --git a/graphene_django/forms/converter.py b/graphene_django/forms/converter.py index 5d17680..9db0a77 100644 --- a/graphene_django/forms/converter.py +++ b/graphene_django/forms/converter.py @@ -1,12 +1,23 @@ 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 ( + Boolean, + Date, + DateTime, + Decimal, + Float, + ID, + Int, + List, + String, + Time, + UUID, +) from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField from ..utils import import_single_dispatch - singledispatch = import_single_dispatch() @@ -52,6 +63,10 @@ def convert_form_field_to_nullboolean(field): @convert_form_field.register(forms.DecimalField) +def convert_field_to_decimal(field): + return Decimal(description=field.help_text, required=field.required) + + @convert_form_field.register(forms.FloatField) def convert_form_field_to_float(field): return Float(description=field.help_text, required=field.required) diff --git a/graphene_django/forms/tests/test_converter.py b/graphene_django/forms/tests/test_converter.py index ccf630f..78b315c 100644 --- a/graphene_django/forms/tests/test_converter.py +++ b/graphene_django/forms/tests/test_converter.py @@ -1,19 +1,19 @@ from django import forms from py.test import raises -import graphene from graphene import ( - String, - Int, Boolean, + Date, + DateTime, + Decimal, Float, ID, - UUID, + Int, List, NonNull, - DateTime, - Date, + String, Time, + UUID, ) from ..converter import convert_form_field @@ -97,8 +97,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 18f34b8..2535fe7 100644 --- a/graphene_django/rest_framework/serializer_converter.py +++ b/graphene_django/rest_framework/serializer_converter.py @@ -115,8 +115,12 @@ def convert_serializer_field_to_bool(field): return graphene.Boolean -@get_graphene_type_from_serializer_field.register(serializers.FloatField) @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.FloatField) def convert_serializer_field_to_float(field): return graphene.Float 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 ) diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index 287ec82..df3771c 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -242,7 +242,7 @@ def test_should_float_convert_float(): assert_conversion(models.FloatField, graphene.Float) -def test_should_float_convert_decimal(): +def test_should_decimal_convert_decimal(): assert_conversion(models.DecimalField, graphene.Decimal) From e0a5d1c58ede37a055960a609a606075a1481610 Mon Sep 17 00:00:00 2001 From: Lucas Date: Mon, 18 Jan 2021 21:39:18 -0800 Subject: [PATCH 11/21] Support "contains" and "overlap" filtering (v2) (#1100) * Fix project setup * Support contains/overlap filters * Add Python 2.7 support * Adjust docstrings * Remove unused fixtures --- graphene_django/compat.py | 5 +- graphene_django/filter/tests/conftest.py | 128 ++++++++++++++++++ .../filter/tests/test_contains_filter.py | 82 +++++++++++ .../filter/tests/test_overlap_filter.py | 84 ++++++++++++ graphene_django/filter/utils.py | 16 +-- graphene_django/tests/test_query.py | 4 +- 6 files changed, 307 insertions(+), 12 deletions(-) create mode 100644 graphene_django/filter/tests/conftest.py create mode 100644 graphene_django/filter/tests/test_contains_filter.py create mode 100644 graphene_django/filter/tests/test_overlap_filter.py diff --git a/graphene_django/compat.py b/graphene_django/compat.py index 8a2b933..537fd1d 100644 --- a/graphene_django/compat.py +++ b/graphene_django/compat.py @@ -6,13 +6,16 @@ try: # Postgres fields are only available in Django with psycopg2 installed # and we cannot have psycopg2 on PyPy from django.contrib.postgres.fields import ( + IntegerRangeField, ArrayField, HStoreField, JSONField as PGJSONField, RangeField, ) except ImportError: - ArrayField, HStoreField, PGJSONField, RangeField = (MissingType,) * 4 + IntegerRangeField, ArrayField, HStoreField, PGJSONField, RangeField = ( + MissingType, + ) * 5 try: # JSONField is only available from Django 3.1 diff --git a/graphene_django/filter/tests/conftest.py b/graphene_django/filter/tests/conftest.py new file mode 100644 index 0000000..0313645 --- /dev/null +++ b/graphene_django/filter/tests/conftest.py @@ -0,0 +1,128 @@ +from mock import MagicMock +import pytest + +from django.db import models +from django.db.models.query import QuerySet +from django_filters import filters +from django_filters import FilterSet +import graphene +from graphene.relay import Node +from graphene_django import DjangoObjectType +from graphene_django.utils import DJANGO_FILTER_INSTALLED + +from ...compat import ArrayField + +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" + ) + ) + + +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 + + +@pytest.fixture +def EventFilterSet(Event): + + from django.contrib.postgres.forms import SimpleArrayField + + class ArrayFilter(filters.Filter): + base_field_class = SimpleArrayField + + class EventFilterSet(FilterSet): + class Meta: + model = Event + fields = { + "name": ["exact"], + } + + tags__contains = ArrayFilter(field_name="tags", lookup_expr="contains") + tags__overlap = ArrayFilter(field_name="tags", lookup_expr="overlap") + + return EventFilterSet + + +@pytest.fixture +def EventType(Event, EventFilterSet): + class EventType(DjangoObjectType): + class Meta: + model = Event + interfaces = (Node,) + filterset_class = EventFilterSet + + return EventType + + +@pytest.fixture +def Query(Event, EventType): + class Query(graphene.ObjectType): + events = DjangoFilterConnectionField(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"],), + ] + + STORE["events"] = events + + m_queryset = MagicMock(spec=QuerySet) + m_queryset.model = Event + + def filter_events(**kwargs): + if "tags__contains" in kwargs: + STORE["events"] = list( + filter( + lambda e: set(kwargs["tags__contains"]).issubset( + set(e.tags) + ), + STORE["events"], + ) + ) + if "tags__overlap" in kwargs: + STORE["events"] = list( + filter( + lambda e: not set(kwargs["tags__overlap"]).isdisjoint( + set(e.tags) + ), + STORE["events"], + ) + ) + + def mock_queryset_filter(*args, **kwargs): + filter_events(**kwargs) + return m_queryset + + def mock_queryset_none(*args, **kwargs): + STORE["events"] = [] + return m_queryset + + def mock_queryset_count(*args, **kwargs): + return len(STORE["events"]) + + m_queryset.all.return_value = m_queryset + 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__ + + return m_queryset + + return Query diff --git a/graphene_django/filter/tests/test_contains_filter.py b/graphene_django/filter/tests/test_contains_filter.py new file mode 100644 index 0000000..35e775e --- /dev/null +++ b/graphene_django/filter/tests/test_contains_filter.py @@ -0,0 +1,82 @@ +import pytest + +from graphene import Schema + +from ...compat import ArrayField, MissingType + + +@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist") +def test_string_contains_multiple(Query): + """ + Test contains filter on a string field. + """ + + schema = Schema(query=Query) + + query = """ + query { + events (tags_Contains: ["concert", "music"]) { + edges { + node { + name + } + } + } + } + """ + result = schema.execute(query) + assert not result.errors + assert result.data["events"]["edges"] == [ + {"node": {"name": "Live Show"}}, + ] + + +@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist") +def test_string_contains_one(Query): + """ + Test contains filter on a string field. + """ + + schema = Schema(query=Query) + + query = """ + query { + events (tags_Contains: ["music"]) { + edges { + node { + name + } + } + } + } + """ + result = schema.execute(query) + assert not result.errors + assert result.data["events"]["edges"] == [ + {"node": {"name": "Live Show"}}, + {"node": {"name": "Musical"}}, + ] + + +@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist") +def test_string_contains_none(Query): + """ + Test contains filter on a string field. + """ + + schema = Schema(query=Query) + + query = """ + query { + events (tags_Contains: []) { + edges { + node { + name + } + } + } + } + """ + result = schema.execute(query) + assert not result.errors + assert result.data["events"]["edges"] == [] diff --git a/graphene_django/filter/tests/test_overlap_filter.py b/graphene_django/filter/tests/test_overlap_filter.py new file mode 100644 index 0000000..32dfa44 --- /dev/null +++ b/graphene_django/filter/tests/test_overlap_filter.py @@ -0,0 +1,84 @@ +import pytest + +from graphene import Schema + +from ...compat import ArrayField, MissingType + + +@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist") +def test_string_overlap_multiple(Query): + """ + Test overlap filter on a string field. + """ + + schema = Schema(query=Query) + + query = """ + query { + events (tags_Overlap: ["concert", "music"]) { + edges { + node { + name + } + } + } + } + """ + result = schema.execute(query) + assert not result.errors + assert result.data["events"]["edges"] == [ + {"node": {"name": "Live Show"}}, + {"node": {"name": "Musical"}}, + {"node": {"name": "Ballet"}}, + ] + + +@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist") +def test_string_overlap_one(Query): + """ + Test overlap filter on a string field. + """ + + schema = Schema(query=Query) + + query = """ + query { + events (tags_Overlap: ["music"]) { + edges { + node { + name + } + } + } + } + """ + result = schema.execute(query) + assert not result.errors + assert result.data["events"]["edges"] == [ + {"node": {"name": "Live Show"}}, + {"node": {"name": "Musical"}}, + ] + + +@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist") +def test_string_overlap_none(Query): + """ + Test overlap filter on a string field. + """ + + schema = Schema(query=Query) + + query = """ + query { + events (tags_Overlap: []) { + edges { + node { + name + } + } + } + } + """ + result = schema.execute(query) + assert not result.errors + assert result.data["events"]["edges"] == [] diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index dce08c7..2be3778 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -1,6 +1,6 @@ import six -from graphene import List +import graphene from django_filters.utils import get_model_field from django_filters.filters import Filter, BaseCSVFilter @@ -41,11 +41,11 @@ def get_filtering_args_from_filterset(filterset_class, type): field = convert_form_field(form_field) - if filter_type in ["in", "range"]: - # Replace CSV filters (`in`, `range`) argument type to be a list of + 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 = List(field.get_type()) + field = graphene.List(field.get_type()) field_type = field.Argument() field_type.description = filter_field.label @@ -71,7 +71,7 @@ def get_filterset_class(filterset_class, **meta): def replace_csv_filters(filterset_class): """ - Replace the "in" and "range" filters (that are not explicitly declared) to not be BaseCSVFilter (BaseInFilter, BaseRangeFilter) objects anymore + 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. This is because those BaseCSVFilter are expecting a string as input with comma separated value but with GraphQl we @@ -81,8 +81,7 @@ def replace_csv_filters(filterset_class): """ for name, filter_field in six.iteritems(filterset_class.base_filters): filter_type = filter_field.lookup_expr - if filter_type == "in": - assert isinstance(filter_field, BaseCSVFilter) + if filter_type in {"in", "contains", "overlap"}: filterset_class.base_filters[name] = InFilter( field_name=filter_field.field_name, lookup_expr=filter_field.lookup_expr, @@ -92,8 +91,7 @@ def replace_csv_filters(filterset_class): **filter_field.extra ) - if filter_type == "range": - assert isinstance(filter_field, BaseCSVFilter) + elif filter_type == "range": filterset_class.base_filters[name] = RangeFilter( field_name=filter_field.field_name, lookup_expr=filter_field.lookup_expr, diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index a2d8373..5ff4466 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -11,7 +11,7 @@ from py.test import raises import graphene from graphene.relay import Node -from ..compat import JSONField, MissingType +from ..compat import IntegerRangeField, MissingType from ..fields import DjangoConnectionField from ..types import DjangoObjectType from ..utils import DJANGO_FILTER_INSTALLED @@ -113,7 +113,7 @@ def test_should_query_well(): assert result.data == expected -@pytest.mark.skipif(JSONField is MissingType, reason="RangeField should exist") +@pytest.mark.skipif(IntegerRangeField is MissingType, reason="RangeField should exist") def test_should_query_postgres_fields(): from django.contrib.postgres.fields import ( IntegerRangeField, From e323e2bc0bef36955a32ae4becd828144e866c44 Mon Sep 17 00:00:00 2001 From: Thomas Leonard <64223923+tcleonard@users.noreply.github.com> Date: Tue, 23 Feb 2021 05:22:09 +0100 Subject: [PATCH 12/21] Add enum support to filters and fix filter typing (v2) (#1114) * - 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 tests Co-authored-by: Thomas Leonard --- docs/filtering.rst | 43 +++ graphene_django/filter/__init__.py | 11 +- graphene_django/filter/fields.py | 4 +- graphene_django/filter/filters.py | 34 +- graphene_django/filter/tests/conftest.py | 42 ++- graphene_django/filter/tests/filters.py | 2 +- ...py => test_array_field_contains_filter.py} | 19 +- .../tests/test_array_field_exact_filter.py | 107 ++++++ ....py => test_array_field_overlap_filter.py} | 12 +- .../filter/tests/test_enum_filtering.py | 144 ++++++++ graphene_django/filter/tests/test_fields.py | 51 ++- .../filter/tests/test_in_filter.py | 344 +++++++++++++++--- graphene_django/filter/utils.py | 131 +++++-- graphene_django/tests/models.py | 6 +- graphene_django/tests/test_query.py | 2 + 15 files changed, 838 insertions(+), 114 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 e366fe2..f197b30 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -228,3 +228,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/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 7d8d2d8..9a4cf36 100644 --- a/graphene_django/filter/fields.py +++ b/graphene_django/filter/fields.py @@ -43,8 +43,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) diff --git a/graphene_django/filter/filters.py b/graphene_django/filter/filters.py index 44832b5..3275ebf 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 @@ -52,7 +54,7 @@ class InFilter(Filter): else: return qs.none() else: - return super(InFilter, self).filter(qs, value) + return super(ListFilter, self).filter(qs, value) def validate_range(value): @@ -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..710234f 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 @@ -32,27 +33,37 @@ def 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()) return Event @pytest.fixture def EventFilterSet(Event): - - from django.contrib.postgres.forms import SimpleArrayField - - class ArrayFilter(filters.Filter): - base_field_class = SimpleArrayField - 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 @@ -70,6 +81,11 @@ def EventType(Event, EventFilterSet): @pytest.fixture def Query(Event, 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 +95,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 +122,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 +145,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..814fd33 --- /dev/null +++ b/graphene_django/filter/tests/test_array_field_exact_filter.py @@ -0,0 +1,107 @@ +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 { + id: ID! + name: String! + tags: [String!]! + tagIds: [Int!]! + randomField: [Boolean!]! +}""" + in schema_str + ) + + assert ( + """type Query { + events(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!]): EventTypeConnection +}""" + 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..650c55e --- /dev/null +++ b/graphene_django/filter/tests/test_enum_filtering.py @@ -0,0 +1,144 @@ +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,) + + class ArticleType(DjangoObjectType): + class Meta: + model = Article + interfaces = (Node,) + 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 { + id: ID! + headline: String! + pubDate: Date! + pubDateTime: DateTime! + reporter: ReporterType! + editor: ReporterType! + lang: ArticleLang! + importance: ArticleImportance +}""" + in schema_str + ) + + assert ( + """type Query { + allReporters(offset: Int, before: String, after: String, first: Int, last: Int): ReporterTypeConnection + allArticles(offset: Int, before: String, after: String, first: Int, last: Int, lang: ArticleLang, lang_In: [ArticleLang], reporter_AChoice: ReporterAChoice, reporter_AChoice_In: [ReporterAChoice]): ArticleTypeConnection +}""" + in schema_str + ) diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index 6de8361..d3e86a5 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, Decimal, Field, ObjectType, Schema, Stri 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 = [] @@ -87,6 +87,7 @@ def test_filter_explicit_filterset_arguments(): "pub_date__gt", "pub_date__lt", "reporter", + "reporter__in", ) @@ -676,7 +677,7 @@ def test_should_query_filter_node_limit(): node { id firstName - articles(lang: "es") { + articles(lang: ES) { edges { node { id @@ -1085,7 +1086,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): @@ -1171,3 +1172,47 @@ 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,) + 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..f0015b6 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,72 @@ 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,) + filter_fields = { + "id": ["exact", "in"], + "name": ["exact", "in"], + "age": ["exact", "in", "range"], + } + + class ReporterNode(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + # choice filter using enum + filter_fields = {"reporter_type": ["exact", "in"]} + + class ArticleNode(DjangoObjectType): + class Meta: + model = Article + interfaces = (Node,) + filterset_class = ArticleFilter + + class FilmNode(DjangoObjectType): + class Meta: + model = Film + interfaces = (Node,) + # 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 + + 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 +96,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 +117,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 +146,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 +183,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 +223,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 +231,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 +247,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/utils.py b/graphene_django/filter/utils.py index 2be3778..2638656 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -2,54 +2,104 @@ import six 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 six.iteritems(filterset_class.base_filters): - 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 = filter_field.label - 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( + type=field_type.get_type(), + description=filter_field.label, + required=required, + ) return args @@ -71,18 +121,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 six.iteritems(filterset_class.base_filters): + # 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, @@ -90,7 +148,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 20f509c..9e7be29 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 5ff4466..9d83f3f 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -412,6 +412,7 @@ def test_should_query_node_filtering(): model = Article interfaces = (Node,) filter_fields = ("lang",) + convert_choices_to_enum = False class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) @@ -534,6 +535,7 @@ def test_should_query_node_multiple_filtering(): model = Article interfaces = (Node,) filter_fields = ("lang", "headline") + convert_choices_to_enum = False class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) From 6f1389c039b9209dcce6cdd8a002a6a6dc6d3213 Mon Sep 17 00:00:00 2001 From: Thomas Leonard <64223923+tcleonard@users.noreply.github.com> Date: Thu, 11 Mar 2021 01:49:58 +0100 Subject: [PATCH 13/21] fix: declaration of required variable in filters v2 (#1136) * fix: declaration of required variable * Add unit test * Fix flaky test * Formatting * Fix for python 2.7 Co-authored-by: Thomas Leonard --- graphene_django/converter.py | 6 ++++- .../filter/tests/test_enum_filtering.py | 24 ++++++++++++++----- graphene_django/filter/tests/test_fields.py | 21 +++++++++++++++- graphene_django/filter/utils.py | 3 +-- 4 files changed, 44 insertions(+), 10 deletions(-) diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 63cc35d..b744e51 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -69,7 +69,11 @@ def convert_choices_to_named_enum_with_descriptions(name, choices): def description(self): return named_choices_descriptions[self.name] - return Enum(name, list(named_choices), type=EnumWithDescriptionsType) + if named_choices == []: + # Python 2.7 doesn't handle enums with lists with zero entries, but works okay with empty sets + named_choices = set() + + return Enum(name, named_choices, type=EnumWithDescriptionsType) def generate_enum_name(django_model_meta, field): diff --git a/graphene_django/filter/tests/test_enum_filtering.py b/graphene_django/filter/tests/test_enum_filtering.py index 650c55e..0fe572c 100644 --- a/graphene_django/filter/tests/test_enum_filtering.py +++ b/graphene_django/filter/tests/test_enum_filtering.py @@ -135,10 +135,22 @@ def test_filter_enum_field_schema_type(schema): in schema_str ) - assert ( - """type Query { - allReporters(offset: Int, before: String, after: String, first: Int, last: Int): ReporterTypeConnection - allArticles(offset: Int, before: String, after: String, first: Int, last: Int, lang: ArticleLang, lang_In: [ArticleLang], reporter_AChoice: ReporterAChoice, reporter_AChoice_In: [ReporterAChoice]): ArticleTypeConnection -}""" - in schema_str + filters = { + "offset": "Int", + "before": "String", + "after": "String", + "first": "Int", + "last": "Int", + "lang": "ArticleLang", + "lang_In": "[ArticleLang]", + "reporter_AChoice": "ReporterAChoice", + "reporter_AChoice_In": "[ReporterAChoice]", + } + + all_articles_filters = ( + schema_str.split(" allArticles(")[1] + .split("): ArticleTypeConnection\n")[0] + .split(", ") ) + for filter_field, gql_type in filters.items(): + assert "{}: {}".format(filter_field, gql_type) in all_articles_filters diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index d3e86a5..86b377a 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, @@ -1216,3 +1216,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 2638656..30213a3 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -39,6 +39,7 @@ def get_filtering_args_from_filterset(filterset_class, type): registry = type._meta.registry for name, filter_field in six.iteritems(filterset_class.base_filters): filter_type = filter_field.lookup_expr + required = filter_field.extra.get("required", False) field_type = None form_field = None @@ -49,8 +50,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 998ed89a4eef59b0ecae27da3a47e167ac1be53f Mon Sep 17 00:00:00 2001 From: Thomas Leonard <64223923+tcleonard@users.noreply.github.com> Date: Wed, 31 Mar 2021 19:32:00 +0200 Subject: [PATCH 14/21] feat: add TypedFilter which allow to explicitly give a filter input GraphQL type (#1142) Co-authored-by: Thomas Leonard --- docs/filtering.rst | 40 ++++- 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 | 156 ++++++++++++++++++ graphene_django/filter/utils.py | 98 +++++------ 11 files changed, 404 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 f197b30..6c84b8b 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -16,7 +16,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 @@ -271,3 +271,41 @@ 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,) + 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 3275ebf..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(ListFilter, self).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..65f025a --- /dev/null +++ b/graphene_django/filter/tests/test_typed_filter.py @@ -0,0 +1,156 @@ +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,) + 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": "ArticleLang", + "lang_In": "[ArticleLang]", + "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 "{}: {}".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 30213a3..c7aa959 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -8,7 +8,7 @@ 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 .filters import ArrayFilter, ListFilter, RangeFilter, TypedFilter from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField @@ -44,60 +44,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( - type=field_type.get_type(), - description=filter_field.label, - required=required, + type=field_type, description=filter_field.label, required=required, ) return args From d52b18a700d2c1cec4b2de1f673bff14e2d18071 Mon Sep 17 00:00:00 2001 From: Rainshaw Date: Wed, 21 Apr 2021 14:05:49 +0800 Subject: [PATCH 15/21] update js version (#1189) --- 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 e81f760..9908e70 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -59,23 +59,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" + graphiql_sri = "sha256-JUMkXBQWZMfJ7fGEsTXalxVA10lzKOS9loXdLjwZKi4=" + graphiql_css_sri = "sha256-Md3vdR7PDzWyo/aGfsFVF4tvS5/eAUWuIsg9QHUusCY=" # 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 a7a8b3dca6cee0ac7e94833535fea65911b507ac Mon Sep 17 00:00:00 2001 From: Yair Silbermintz Date: Mon, 7 Feb 2022 09:16:41 -0500 Subject: [PATCH 16/21] Replace calls to methods removed in Django v4 (#1275) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Replace calls to deprecated methods * Fix test config & Replace additional methods removed in django 4.0 * Update tox for official Django 4 release * 2.16.0 * Revert version update * Remove duplicate entry Co-authored-by: Jeremy Stretch * Limit max Django version Co-authored-by: Jeremy Stretch * Remove Python 3.5 (deprecated) from tox Co-authored-by: Jeremy Stretch Co-authored-by: Ülgen Sarıkavak Co-authored-by: Jeremy Stretch --- .github/workflows/tests.yml | 27 ++++++++++++++++++++----- graphene_django/tests/models.py | 2 +- graphene_django/tests/test_converter.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/utils.py | 4 ++-- tox.ini | 22 ++++++++++---------- 8 files changed, 44 insertions(+), 27 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b9e57b5..b4f023f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,12 +8,29 @@ jobs: strategy: max-parallel: 4 matrix: - django: ["1.11", "2.2", "3.0", "3.1"] - python-version: ["3.6", "3.7", "3.8"] + django: ["2.2", "3.0", "3.1", "3.2", "4.0"] + python-version: ["3.8", "3.9"] include: - - django: "1.11" - python-version: "2.7" - + - django: "2.2" + python-version: "3.6" + - django: "2.2" + python-version: "3.7" + - django: "3.0" + python-version: "3.6" + - django: "3.0" + python-version: "3.7" + - django: "3.1" + python-version: "3.6" + - django: "3.1" + python-version: "3.7" + - django: "3.2" + python-version: "3.6" + - django: "3.2" + python-version: "3.7" + - django: "3.2" + python-version: "3.10" + - django: "4.0" + python-version: "3.10" steps: - uses: actions/checkout@v1 - name: Set up Python ${{ matrix.python-version }} diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py index 9e7be29..659cf6d 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -1,7 +1,7 @@ from __future__ import absolute_import from django.db import models -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ CHOICES = ((1, "this"), (2, _("that"))) diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index df3771c..7b38a45 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -2,7 +2,7 @@ from collections import namedtuple import pytest from django.db import models -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from py.test import raises import graphene diff --git a/graphene_django/tests/urls.py b/graphene_django/tests/urls.py index 66b3fc4..f2faae2 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 re_path from ..views import GraphQLView urlpatterns = [ - url(r"^graphql/batch", GraphQLView.as_view(batch=True)), - url(r"^graphql", GraphQLView.as_view(graphiql=True)), + re_path(r"^graphql/batch", GraphQLView.as_view(batch=True)), + re_path(r"^graphql", GraphQLView.as_view(graphiql=True)), ] diff --git a/graphene_django/tests/urls_inherited.py b/graphene_django/tests/urls_inherited.py index 6fa8019..815d04d 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 re_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 = [re_path(r"^graphql/inherited/$", CustomGraphQLView.as_view())] diff --git a/graphene_django/tests/urls_pretty.py b/graphene_django/tests/urls_pretty.py index 1133c87..635d4f3 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 re_path from ..views import GraphQLView from .schema_view import schema -urlpatterns = [url(r"^graphql", GraphQLView.as_view(schema=schema, pretty=True))] +urlpatterns = [re_path(r"^graphql", GraphQLView.as_view(schema=schema, pretty=True))] diff --git a/graphene_django/utils/utils.py b/graphene_django/utils/utils.py index b1c9a7d..ff3b7f3 100644 --- a/graphene_django/utils/utils.py +++ b/graphene_django/utils/utils.py @@ -3,7 +3,7 @@ import inspect import six from django.db import connection, models, transaction from django.db.models.manager import Manager -from django.utils.encoding import force_text +from django.utils.encoding import force_str from django.utils.functional import Promise from graphene.utils.str_converters import to_camel_case @@ -26,7 +26,7 @@ def isiterable(value): def _camelize_django_str(s): if isinstance(s, Promise): - s = force_text(s) + s = force_str(s) return to_camel_case(s) if isinstance(s, six.string_types) else s diff --git a/tox.ini b/tox.ini index d2d3065..e8d0188 100644 --- a/tox.ini +++ b/tox.ini @@ -1,24 +1,26 @@ [tox] envlist = - py{27,35,36,37,38}-django{111,20,21,22,master}, - py{36,37,38}-django{30,31}, + py{36,37,38,39}-django22, + py{36,37,38,39}-django{30,31}, + py{36,37,38,39,310}-django32, + py{38,39,310}-django{40,master}, black,flake8 [gh-actions] python = - 2.7: py27 3.6: py36 3.7: py37 3.8: py38 + 3.9: py39 + 3.10: py310 [gh-actions:env] DJANGO = - 1.11: django111 - 2.0: django20 - 2.1: django21 2.2: django22 3.0: django30 3.1: django31 + 3.2: django32 + 4.0: django40 master: djangomaster [testenv] @@ -29,13 +31,11 @@ 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 + django30: Django>=3.0,<3.1 django31: Django>=3.1,<3.2 + django32: Django>=3.2,<4.0 + django40: Django>=4.0,<4.1 djangomaster: https://github.com/django/django/archive/master.zip commands = {posargs:py.test --cov=graphene_django graphene_django examples} From ed4ee98596a2ecb9c145a3abd8eae85cac1fe5f0 Mon Sep 17 00:00:00 2001 From: Peter Paul Kiefer Date: Sun, 13 Feb 2022 06:50:04 +0100 Subject: [PATCH 17/21] V2 has broken liks too see #1309 (#1310) * fix broken links for the v2 branch v2 brach has broken links to read the docs too I additionally found a link to the git hub master tree, which should be changed to main. * #1295 github link fixed (master->v2) Co-authored-by: Peter Paul Kiefer --- docs/filtering.rst | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/filtering.rst b/docs/filtering.rst index 6c84b8b..a131b30 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -2,9 +2,9 @@ Filtering ========= Graphene-Django integrates with -`django-filter `__ (2.x for +`django-filter `__ (2.x for Python 3 or 1.x for Python 2) to provide filtering of results. See the `usage -documentation `__ +documentation `__ for details on the format for ``filter_fields``. This filtering is automatically available when implementing a ``relay.Node``. @@ -27,7 +27,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 ----------------- @@ -35,7 +35,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: @@ -163,7 +163,7 @@ in unison with the ``filter_fields`` parameter: animal = relay.Node.Field(AnimalNode) 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 12ec3ca4acbfc49ab2f3567dbe8f287a924402b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Thu, 18 Aug 2022 12:48:51 +0300 Subject: [PATCH 18/21] Introduce pre-commit config for flake8 (#1338) --- .github/workflows/lint.yml | 4 ++-- .pre-commit-config.yaml | 8 ++++++++ setup.py | 6 +++--- tox.ini | 8 ++++---- 4 files changed, 17 insertions(+), 9 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 20cf7fb..8b76b57 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -16,7 +16,7 @@ jobs: run: | python -m pip install --upgrade pip pip install tox - - name: Run lint 💅 + - name: Run pre-commit 💅 run: tox env: - TOXENV: flake8 + TOXENV: pre-commit diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..cb9fab4 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,8 @@ +default_language_version: + python: python3.8 +repos: +- repo: https://github.com/PyCQA/flake8 + rev: 5.0.4 + hooks: + - id: flake8 + additional_dependencies: [flake8-bugbear==22.7.1] diff --git a/setup.py b/setup.py index e6615b8..9743328 100644 --- a/setup.py +++ b/setup.py @@ -27,9 +27,9 @@ tests_require = [ dev_requires = [ "black==19.10b0", - "flake8==3.7.9", - "flake8-black==0.1.1", - "flake8-bugbear==20.1.4", + "flake8>=5,<6", + "flake8-black==0.3.3", + "flake8-bugbear==22.7.1", ] + tests_require setup( diff --git a/tox.ini b/tox.ini index e8d0188..9b4bdd1 100644 --- a/tox.ini +++ b/tox.ini @@ -45,8 +45,8 @@ deps = -e.[dev] commands = black --exclude "/migrations/" graphene_django examples setup.py --check -[testenv:flake8] -basepython = python3.8 -deps = -e.[dev] +[testenv:pre-commit] +skip_install = true +deps = pre-commit commands = - flake8 graphene_django examples setup.py + pre-commit run --all-files --show-diff-on-failure From e980cede386132effd0927482bff462a5fc9b7c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Thu, 18 Aug 2022 13:02:41 +0300 Subject: [PATCH 19/21] Upgrade Python version in CI (#1339) --- .github/workflows/deploy.yml | 8 ++++---- .github/workflows/lint.yml | 8 ++++---- .github/workflows/tests.yml | 4 ++-- .pre-commit-config.yaml | 2 +- tox.ini | 3 ++- 5 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1cd1011..6cce61d 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@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v4 with: - python-version: 3.8 + python-version: "3.10" - name: Build wheel and source tarball run: | pip install wheel diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 8b76b57..a458cd1 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@v3 + - name: Set up Python 3.10 + uses: actions/setup-python@v4 with: - python-version: 3.8 + python-version: "3.10" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b4f023f..045d73f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -32,9 +32,9 @@ jobs: - django: "4.0" python-version: "3.10" steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v1 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cb9fab4..08aa276 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,5 +1,5 @@ default_language_version: - python: python3.8 + python: python3.10 repos: - repo: https://github.com/PyCQA/flake8 rev: 5.0.4 diff --git a/tox.ini b/tox.ini index 9b4bdd1..afe79c6 100644 --- a/tox.ini +++ b/tox.ini @@ -40,12 +40,13 @@ deps = commands = {posargs:py.test --cov=graphene_django graphene_django examples} [testenv:black] -basepython = python3.8 +basepython = python3.10 deps = -e.[dev] commands = black --exclude "/migrations/" graphene_django examples setup.py --check [testenv:pre-commit] +basepython = python3.10 skip_install = true deps = pre-commit commands = From 8383bdc5aabe8b107eeba0e898787153025d6f28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Fri, 19 Aug 2022 09:15:44 +0300 Subject: [PATCH 20/21] pre-commit & black (#1340) * Call black via pre-commit * Apply black --- .pre-commit-config.yaml | 6 ++ Makefile | 6 +- docs/conf.py | 16 ++-- docs/schema.py | 75 +++++++++---------- .../ingredients/migrations/0001_initial.py | 42 ++++++++--- .../migrations/0002_auto_20161104_0050.py | 6 +- .../migrations/0003_auto_20181018_1746.py | 6 +- .../recipes/migrations/0001_initial.py | 58 +++++++++++--- .../migrations/0002_auto_20161104_0106.py | 22 ++++-- .../migrations/0003_auto_20181018_1728.py | 16 +++- .../ingredients/migrations/0001_initial.py | 42 ++++++++--- .../migrations/0002_auto_20161104_0050.py | 6 +- .../recipes/migrations/0001_initial.py | 58 +++++++++++--- .../migrations/0002_auto_20161104_0106.py | 22 ++++-- graphene_django/fields.py | 5 +- .../filter/filters/global_id_filter.py | 2 +- graphene_django/filter/filterset.py | 10 +-- graphene_django/filter/tests/conftest.py | 20 ++++- .../filter/tests/test_enum_filtering.py | 23 +++++- graphene_django/filter/tests/test_fields.py | 14 +++- .../filter/tests/test_in_filter.py | 20 ++++- .../filter/tests/test_typed_filter.py | 15 +++- graphene_django/filter/utils.py | 4 +- graphene_django/tests/models.py | 4 +- graphene_django/tests/test_query.py | 16 +++- .../utils/tests/test_str_converters.py | 2 +- setup.py | 2 +- tox.ini | 6 -- 28 files changed, 364 insertions(+), 160 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 08aa276..021d38b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,8 +1,14 @@ default_language_version: python: python3.10 + repos: - repo: https://github.com/PyCQA/flake8 rev: 5.0.4 hooks: - id: flake8 additional_dependencies: [flake8-bugbear==22.7.1] + +- repo: https://github.com/psf/black + rev: 22.6.0 + hooks: + - id: black diff --git a/Makefile b/Makefile index b850ae8..8f7ea0d 100644 --- a/Makefile +++ b/Makefile @@ -14,11 +14,7 @@ test: tests # Alias test -> tests .PHONY: format format: - black --exclude "/migrations/" graphene_django examples setup.py - -.PHONY: lint -lint: - flake8 graphene_django examples + pre-commit run --all-files .PHONY: docs ## Generate docs docs: dev-setup diff --git a/docs/conf.py b/docs/conf.py index a485d5b..b83e0f0 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -60,18 +60,18 @@ source_suffix = ".rst" master_doc = "index" # General information about the project. -project = u"Graphene Django" -copyright = u"Graphene 2017" -author = u"Syrus Akbary" +project = "Graphene Django" +copyright = "Graphene 2017" +author = "Syrus Akbary" # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. -version = u"1.0" +version = "1.0" # The full version, including alpha/beta/rc tags. -release = u"1.0.dev" +release = "1.0.dev" # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. @@ -276,7 +276,7 @@ latex_elements = { # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ - (master_doc, "Graphene.tex", u"Graphene Documentation", u"Syrus Akbary", "manual") + (master_doc, "Graphene.tex", "Graphene Documentation", "Syrus Akbary", "manual") ] # The name of an image file (relative to this directory) to place at the top of @@ -317,7 +317,7 @@ latex_documents = [ # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ - (master_doc, "graphene_django", u"Graphene Django Documentation", [author], 1) + (master_doc, "graphene_django", "Graphene Django Documentation", [author], 1) ] # If true, show URL addresses after external links. @@ -334,7 +334,7 @@ texinfo_documents = [ ( master_doc, "Graphene-Django", - u"Graphene Django Documentation", + "Graphene Django Documentation", author, "Graphene Django", "One line description of project.", diff --git a/docs/schema.py b/docs/schema.py index 3d9b2fa..914b656 100644 --- a/docs/schema.py +++ b/docs/schema.py @@ -1,58 +1,55 @@ - import graphene +import graphene - from graphene_django.types import DjangoObjectType +from graphene_django.types import DjangoObjectType - from cookbook.ingredients.models import Category, Ingredient +from cookbook.ingredients.models import Category, Ingredient - class CategoryType(DjangoObjectType): - class Meta: - model = Category +class CategoryType(DjangoObjectType): + class Meta: + model = Category - class IngredientType(DjangoObjectType): - class Meta: - model = Ingredient +class IngredientType(DjangoObjectType): + class Meta: + model = Ingredient - class Query(object): - category = graphene.Field(CategoryType, - id=graphene.Int(), - name=graphene.String()) - all_categories = graphene.List(CategoryType) +class Query(object): + category = graphene.Field(CategoryType, id=graphene.Int(), name=graphene.String()) + all_categories = graphene.List(CategoryType) + ingredient = graphene.Field( + IngredientType, id=graphene.Int(), name=graphene.String() + ) + all_ingredients = graphene.List(IngredientType) - ingredient = graphene.Field(IngredientType, - id=graphene.Int(), - name=graphene.String()) - all_ingredients = graphene.List(IngredientType) + def resolve_all_categories(self, info, **kwargs): + return Category.objects.all() - def resolve_all_categories(self, info, **kwargs): - return Category.objects.all() + def resolve_all_ingredients(self, info, **kwargs): + return Ingredient.objects.all() - def resolve_all_ingredients(self, info, **kwargs): - return Ingredient.objects.all() + def resolve_category(self, info, **kwargs): + id = kwargs.get("id") + name = kwargs.get("name") - def resolve_category(self, info, **kwargs): - id = kwargs.get('id') - name = kwargs.get('name') + if id is not None: + return Category.objects.get(pk=id) - if id is not None: - return Category.objects.get(pk=id) + if name is not None: + return Category.objects.get(name=name) - if name is not None: - return Category.objects.get(name=name) + return None - return None + def resolve_ingredient(self, info, **kwargs): + id = kwargs.get("id") + name = kwargs.get("name") - def resolve_ingredient(self, info, **kwargs): - id = kwargs.get('id') - name = kwargs.get('name') + if id is not None: + return Ingredient.objects.get(pk=id) - if id is not None: - return Ingredient.objects.get(pk=id) + if name is not None: + return Ingredient.objects.get(name=name) - if name is not None: - return Ingredient.objects.get(name=name) - - return None \ No newline at end of file + return None diff --git a/examples/cookbook-plain/cookbook/ingredients/migrations/0001_initial.py b/examples/cookbook-plain/cookbook/ingredients/migrations/0001_initial.py index 0494923..ee8cadd 100644 --- a/examples/cookbook-plain/cookbook/ingredients/migrations/0001_initial.py +++ b/examples/cookbook-plain/cookbook/ingredients/migrations/0001_initial.py @@ -10,24 +10,46 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='Category', + name="Category", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), ], ), migrations.CreateModel( - name='Ingredient', + name="Ingredient", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), - ('notes', models.TextField()), - ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredients', to='ingredients.Category')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ("notes", models.TextField()), + ( + "category", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ingredients", + to="ingredients.Category", + ), + ), ], ), ] diff --git a/examples/cookbook-plain/cookbook/ingredients/migrations/0002_auto_20161104_0050.py b/examples/cookbook-plain/cookbook/ingredients/migrations/0002_auto_20161104_0050.py index 359d4fc..0f3cab5 100644 --- a/examples/cookbook-plain/cookbook/ingredients/migrations/0002_auto_20161104_0050.py +++ b/examples/cookbook-plain/cookbook/ingredients/migrations/0002_auto_20161104_0050.py @@ -8,13 +8,13 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('ingredients', '0001_initial'), + ("ingredients", "0001_initial"), ] operations = [ migrations.AlterField( - model_name='ingredient', - name='notes', + model_name="ingredient", + name="notes", field=models.TextField(blank=True, null=True), ), ] diff --git a/examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py b/examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py index 184e79e..8015d1f 100644 --- a/examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py +++ b/examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py @@ -6,12 +6,12 @@ from django.db import migrations class Migration(migrations.Migration): dependencies = [ - ('ingredients', '0002_auto_20161104_0050'), + ("ingredients", "0002_auto_20161104_0050"), ] operations = [ migrations.AlterModelOptions( - name='category', - options={'verbose_name_plural': 'Categories'}, + name="category", + options={"verbose_name_plural": "Categories"}, ), ] diff --git a/examples/cookbook-plain/cookbook/recipes/migrations/0001_initial.py b/examples/cookbook-plain/cookbook/recipes/migrations/0001_initial.py index 338c71a..a43fa7d 100644 --- a/examples/cookbook-plain/cookbook/recipes/migrations/0001_initial.py +++ b/examples/cookbook-plain/cookbook/recipes/migrations/0001_initial.py @@ -11,26 +11,62 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('ingredients', '0001_initial'), + ("ingredients", "0001_initial"), ] operations = [ migrations.CreateModel( - name='Recipe', + name="Recipe", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=100)), - ('instructions', models.TextField()), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=100)), + ("instructions", models.TextField()), ], ), migrations.CreateModel( - name='RecipeIngredient', + name="RecipeIngredient", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('amount', models.FloatField()), - ('unit', models.CharField(choices=[('kg', 'Kilograms'), ('l', 'Litres'), ('', 'Units')], max_length=20)), - ('ingredient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='used_by', to='ingredients.Ingredient')), - ('recipes', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='amounts', to='recipes.Recipe')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("amount", models.FloatField()), + ( + "unit", + models.CharField( + choices=[("kg", "Kilograms"), ("l", "Litres"), ("", "Units")], + max_length=20, + ), + ), + ( + "ingredient", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="used_by", + to="ingredients.Ingredient", + ), + ), + ( + "recipes", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="amounts", + to="recipes.Recipe", + ), + ), ], ), ] diff --git a/examples/cookbook-plain/cookbook/recipes/migrations/0002_auto_20161104_0106.py b/examples/cookbook-plain/cookbook/recipes/migrations/0002_auto_20161104_0106.py index f135392..6a8d1bf 100644 --- a/examples/cookbook-plain/cookbook/recipes/migrations/0002_auto_20161104_0106.py +++ b/examples/cookbook-plain/cookbook/recipes/migrations/0002_auto_20161104_0106.py @@ -8,18 +8,26 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('recipes', '0001_initial'), + ("recipes", "0001_initial"), ] operations = [ migrations.RenameField( - model_name='recipeingredient', - old_name='recipes', - new_name='recipe', + model_name="recipeingredient", + old_name="recipes", + new_name="recipe", ), migrations.AlterField( - model_name='recipeingredient', - name='unit', - field=models.CharField(choices=[(b'unit', b'Units'), (b'kg', b'Kilograms'), (b'l', b'Litres'), (b'st', b'Shots')], max_length=20), + model_name="recipeingredient", + name="unit", + field=models.CharField( + choices=[ + (b"unit", b"Units"), + (b"kg", b"Kilograms"), + (b"l", b"Litres"), + (b"st", b"Shots"), + ], + max_length=20, + ), ), ] diff --git a/examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py b/examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py index 7a8df49..c54855b 100644 --- a/examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py +++ b/examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py @@ -6,13 +6,21 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('recipes', '0002_auto_20161104_0106'), + ("recipes", "0002_auto_20161104_0106"), ] operations = [ migrations.AlterField( - model_name='recipeingredient', - name='unit', - field=models.CharField(choices=[('unit', 'Units'), ('kg', 'Kilograms'), ('l', 'Litres'), ('st', 'Shots')], max_length=20), + model_name="recipeingredient", + name="unit", + field=models.CharField( + choices=[ + ("unit", "Units"), + ("kg", "Kilograms"), + ("l", "Litres"), + ("st", "Shots"), + ], + max_length=20, + ), ), ] diff --git a/examples/cookbook/cookbook/ingredients/migrations/0001_initial.py b/examples/cookbook/cookbook/ingredients/migrations/0001_initial.py index 0494923..ee8cadd 100644 --- a/examples/cookbook/cookbook/ingredients/migrations/0001_initial.py +++ b/examples/cookbook/cookbook/ingredients/migrations/0001_initial.py @@ -10,24 +10,46 @@ class Migration(migrations.Migration): initial = True - dependencies = [ - ] + dependencies = [] operations = [ migrations.CreateModel( - name='Category', + name="Category", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), ], ), migrations.CreateModel( - name='Ingredient', + name="Ingredient", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), - ('notes', models.TextField()), - ('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredients', to='ingredients.Category')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=100)), + ("notes", models.TextField()), + ( + "category", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="ingredients", + to="ingredients.Category", + ), + ), ], ), ] diff --git a/examples/cookbook/cookbook/ingredients/migrations/0002_auto_20161104_0050.py b/examples/cookbook/cookbook/ingredients/migrations/0002_auto_20161104_0050.py index 359d4fc..0f3cab5 100644 --- a/examples/cookbook/cookbook/ingredients/migrations/0002_auto_20161104_0050.py +++ b/examples/cookbook/cookbook/ingredients/migrations/0002_auto_20161104_0050.py @@ -8,13 +8,13 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('ingredients', '0001_initial'), + ("ingredients", "0001_initial"), ] operations = [ migrations.AlterField( - model_name='ingredient', - name='notes', + model_name="ingredient", + name="notes", field=models.TextField(blank=True, null=True), ), ] diff --git a/examples/cookbook/cookbook/recipes/migrations/0001_initial.py b/examples/cookbook/cookbook/recipes/migrations/0001_initial.py index 338c71a..a43fa7d 100644 --- a/examples/cookbook/cookbook/recipes/migrations/0001_initial.py +++ b/examples/cookbook/cookbook/recipes/migrations/0001_initial.py @@ -11,26 +11,62 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('ingredients', '0001_initial'), + ("ingredients", "0001_initial"), ] operations = [ migrations.CreateModel( - name='Recipe', + name="Recipe", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('title', models.CharField(max_length=100)), - ('instructions', models.TextField()), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("title", models.CharField(max_length=100)), + ("instructions", models.TextField()), ], ), migrations.CreateModel( - name='RecipeIngredient', + name="RecipeIngredient", fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('amount', models.FloatField()), - ('unit', models.CharField(choices=[('kg', 'Kilograms'), ('l', 'Litres'), ('', 'Units')], max_length=20)), - ('ingredient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='used_by', to='ingredients.Ingredient')), - ('recipes', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='amounts', to='recipes.Recipe')), + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("amount", models.FloatField()), + ( + "unit", + models.CharField( + choices=[("kg", "Kilograms"), ("l", "Litres"), ("", "Units")], + max_length=20, + ), + ), + ( + "ingredient", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="used_by", + to="ingredients.Ingredient", + ), + ), + ( + "recipes", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="amounts", + to="recipes.Recipe", + ), + ), ], ), ] diff --git a/examples/cookbook/cookbook/recipes/migrations/0002_auto_20161104_0106.py b/examples/cookbook/cookbook/recipes/migrations/0002_auto_20161104_0106.py index f135392..6a8d1bf 100644 --- a/examples/cookbook/cookbook/recipes/migrations/0002_auto_20161104_0106.py +++ b/examples/cookbook/cookbook/recipes/migrations/0002_auto_20161104_0106.py @@ -8,18 +8,26 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('recipes', '0001_initial'), + ("recipes", "0001_initial"), ] operations = [ migrations.RenameField( - model_name='recipeingredient', - old_name='recipes', - new_name='recipe', + model_name="recipeingredient", + old_name="recipes", + new_name="recipe", ), migrations.AlterField( - model_name='recipeingredient', - name='unit', - field=models.CharField(choices=[(b'unit', b'Units'), (b'kg', b'Kilograms'), (b'l', b'Litres'), (b'st', b'Shots')], max_length=20), + model_name="recipeingredient", + name="unit", + field=models.CharField( + choices=[ + (b"unit", b"Units"), + (b"kg", b"Kilograms"), + (b"l", b"Litres"), + (b"st", b"Shots"), + ], + max_length=20, + ), ), ] diff --git a/graphene_django/fields.py b/graphene_django/fields.py index fdf95aa..eead5b3 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -66,7 +66,10 @@ class DjangoListField(Field): _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, + parent_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 0fd0a82..8ffb0b5 100644 --- a/graphene_django/filter/filterset.py +++ b/graphene_django/filter/filterset.py @@ -19,8 +19,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( @@ -60,8 +60,7 @@ if VERSION[0] < 2: 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), @@ -70,8 +69,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 710234f..4d5b810 100644 --- a/graphene_django/filter/tests/conftest.py +++ b/graphene_django/filter/tests/conftest.py @@ -92,10 +92,22 @@ def Query(Event, 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 0fe572c..73b628b 100644 --- a/graphene_django/filter/tests/test_enum_filtering.py +++ b/graphene_django/filter/tests/test_enum_filtering.py @@ -52,13 +52,22 @@ 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", ) @@ -78,7 +87,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 86b377a..61e6548 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -1208,13 +1208,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_in_filter.py b/graphene_django/filter/tests/test_in_filter.py index f0015b6..f022aa0 100644 --- a/graphene_django/filter/tests/test_in_filter.py +++ b/graphene_django/filter/tests/test_in_filter.py @@ -374,16 +374,28 @@ def test_enum_in_filter(query): """ Reporter.objects.create( - first_name="John", last_name="Doe", email="john@doe.com", reporter_type=1 + 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 + 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 + 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 + first_name="Jack", + last_name="Black", + email="jack@black.com", + reporter_type=None, ) schema = Schema(query=query) diff --git a/graphene_django/filter/tests/test_typed_filter.py b/graphene_django/filter/tests/test_typed_filter.py index 65f025a..051144d 100644 --- a/graphene_django/filter/tests/test_typed_filter.py +++ b/graphene_django/filter/tests/test_typed_filter.py @@ -103,13 +103,22 @@ 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", + headline="A", + reporter=reporter, + editor=reporter, + lang="es", ) Article.objects.create( - headline="B", reporter=reporter, editor=reporter, lang="es", + headline="B", + reporter=reporter, + editor=reporter, + lang="es", ) Article.objects.create( - headline="C", reporter=reporter, editor=reporter, lang="en", + 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 c7aa959..0743773 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -99,7 +99,9 @@ def get_filtering_args_from_filterset(filterset_class, type): field_type = graphene.List(field_type) args[name] = graphene.Argument( - type=field_type, description=filter_field.label, required=required, + type=field_type, + description=filter_field.label, + required=required, ) return args diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py index 659cf6d..7b76cd3 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -50,7 +50,7 @@ class Reporter(models.Model): "Reporter Type", null=True, blank=True, - choices=[(1, u"Regular"), (2, u"CNN Reporter")], + choices=[(1, "Regular"), (2, "CNN Reporter")], ) def __str__(self): # __unicode__ on Python 2 @@ -109,7 +109,7 @@ class Article(models.Model): "Importance", null=True, blank=True, - choices=[(1, u"Very important"), (2, u"Not as important")], + choices=[(1, "Very important"), (2, "Not as important")], ) def __str__(self): # __unicode__ on Python 2 diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 9d83f3f..fd43fb0 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1444,7 +1444,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 @@ -1484,7 +1488,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 @@ -1551,6 +1557,10 @@ 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/utils/tests/test_str_converters.py b/graphene_django/utils/tests/test_str_converters.py index 24064b2..fc466f6 100644 --- a/graphene_django/utils/tests/test_str_converters.py +++ b/graphene_django/utils/tests/test_str_converters.py @@ -7,4 +7,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 9743328..cadf970 100644 --- a/setup.py +++ b/setup.py @@ -26,7 +26,7 @@ tests_require = [ dev_requires = [ - "black==19.10b0", + "black==22.6.0", "flake8>=5,<6", "flake8-black==0.3.3", "flake8-bugbear==22.7.1", diff --git a/tox.ini b/tox.ini index afe79c6..952ba68 100644 --- a/tox.ini +++ b/tox.ini @@ -39,12 +39,6 @@ deps = djangomaster: https://github.com/django/django/archive/master.zip commands = {posargs:py.test --cov=graphene_django graphene_django examples} -[testenv:black] -basepython = python3.10 -deps = -e.[dev] -commands = - black --exclude "/migrations/" graphene_django examples setup.py --check - [testenv:pre-commit] basepython = python3.10 skip_install = true From ede3880abbd7e6464b9d7edf08df692cc7023662 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Fri, 2 Sep 2022 18:55:39 +0300 Subject: [PATCH 21/21] Sync trove classifiers with tox.ini (#1341) * Sync trove classifiers with tox.ini * No need for Python 3 condition anymore --- setup.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/setup.py b/setup.py index cadf970..0ac0d91 100644 --- a/setup.py +++ b/setup.py @@ -19,8 +19,7 @@ tests_require = [ "coveralls", "mock", "pytz", - "django-filter<2;python_version<'3'", - "django-filter>=2;python_version>='3'", + "django-filter>=2", "pytest-django>=3.3.2", ] + rest_framework_require @@ -45,25 +44,26 @@ setup( "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", "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 :: 1.11", "Framework :: Django :: 2.2", "Framework :: Django :: 3.0", + "Framework :: Django :: 3.1", + "Framework :: Django :: 3.2", + "Framework :: Django :: 4.0", ], keywords="api graphql protocol rest relay graphene", packages=find_packages(exclude=["tests", "examples", "examples.*"]), install_requires=[ - "six>=1.10.0", "graphene>=2.1.7,<3", "graphql-core>=2.1.0,<3", - "Django>=1.11", + "Django>=2.2", "singledispatch>=3.4.0.3", "promise>=2.1", "text-unidecode",