From 13352216a47ba2ce8d9d1329d5bb16593bd1d16c Mon Sep 17 00:00:00 2001 From: Akhil Gopi Date: Sat, 7 Mar 2020 21:47:45 +0530 Subject: [PATCH 01/79] Improv/documentation fixes (#895) * Bump up the minimum support Django version. * Update documentation to mention support for Django 1.11. Co-authored-by: Akhil --- docs/authorization.rst | 2 +- docs/installation.rst | 16 +++++++++++++++- docs/tutorial-plain.rst | 2 +- docs/tutorial-relay.rst | 2 +- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/authorization.rst b/docs/authorization.rst index ebc9795..63123b0 100644 --- a/docs/authorization.rst +++ b/docs/authorization.rst @@ -166,7 +166,7 @@ To restrict users from accessing the GraphQL API page the standard Django LoginR After this, you can use the new ``PrivateGraphQLView`` in the project's URL Configuration file ``url.py``: -For Django 1.9 and below: +For Django 1.11: .. code:: python diff --git a/docs/installation.rst b/docs/installation.rst index 52f2520..048a994 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -8,7 +8,7 @@ Requirements Graphene-Django currently supports the following versions of Django: -* Django 2.X +* >= Django 1.11 Installation ------------ @@ -32,6 +32,20 @@ Add ``graphene_django`` to the ``INSTALLED_APPS`` in the ``settings.py`` file of We need to add a ``graphql`` URL to the ``urls.py`` of your Django project: +For Django 1.11: + +.. code:: python + + from django.conf.urls import url + from graphene_django.views import GraphQLView + + urlpatterns = [ + # ... + url(r"graphql", GraphQLView.as_view(graphiql=True)), + ] + +For Django 2.0 and above: + .. code:: python from django.urls import path diff --git a/docs/tutorial-plain.rst b/docs/tutorial-plain.rst index c3ee269..e80f9ab 100644 --- a/docs/tutorial-plain.rst +++ b/docs/tutorial-plain.rst @@ -286,7 +286,7 @@ from the command line. $ python ./manage.py runserver Performing system checks... - Django version 1.9, using settings 'cookbook.settings' + Django version 1.11, using settings 'cookbook.settings' Starting development server at http://127.0.0.1:8000/ Quit the server with CONTROL-C. diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst index 5f8bd64..e900ea1 100644 --- a/docs/tutorial-relay.rst +++ b/docs/tutorial-relay.rst @@ -277,7 +277,7 @@ from the command line. $ python ./manage.py runserver Performing system checks... - Django version 1.9, using settings 'cookbook.settings' + Django version 1.11, using settings 'cookbook.settings' Starting development server at http://127.0.0.1:8000/ Quit the server with CONTROL-C. From b8e598d66d87e291591bc65be41bad2087d788d3 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Fri, 13 Mar 2020 10:04:25 +0000 Subject: [PATCH 02/79] =?UTF-8?q?Add=20options=20to=20override=20how=20Dja?= =?UTF-8?q?ngo=20Choice=20fields=20are=20converted=20t=E2=80=A6=20(#860)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add new setting to create unique enum names * Add specific tests for name generation * Add schema test * Rename settings field * Rename setting * Add custom function setting * Add documentation * Use format instead of f strings * Update graphene_django/converter.py Co-Authored-By: Syrus Akbary * Fix tests * Update docs * Import function through import_string function Co-authored-by: Syrus Akbary --- docs/settings.rst | 30 +++++++++ graphene_django/converter.py | 31 +++++++++- graphene_django/settings.py | 3 + graphene_django/tests/test_converter.py | 30 ++++++++- graphene_django/tests/test_types.py | 81 +++++++++++++++++++++++++ 5 files changed, 171 insertions(+), 4 deletions(-) diff --git a/docs/settings.rst b/docs/settings.rst index 4776ce0..5a7e4c9 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -140,3 +140,33 @@ Default: ``False`` # 'messages': ['This field is required.'], # } # ] + + +``DJANGO_CHOICE_FIELD_ENUM_V3_NAMING`` +-------------------------------------- + +Set to ``True`` to use the new naming format for the auto generated Enum types from Django choice fields. The new format looks like this: ``{app_label}{object_name}{field_name}Choices`` + +Default: ``False`` + + +``DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME`` +-------------------------------------- + +Define the path of a function that takes the Django choice field and returns a string to completely customise the naming for the Enum type. + +If set to a function then the ``DJANGO_CHOICE_FIELD_ENUM_V3_NAMING`` setting is ignored. + +Default: ``None`` + +.. code:: python + + # myapp.utils + def enum_naming(field): + if isinstance(field.model, User): + return f"CustomUserEnum{field.name.title()}" + return f"CustomEnum{field.name.title()}" + + GRAPHENE = { + 'DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME': "myapp.utils.enum_naming" + } diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 8b93d17..bd8f79d 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -1,6 +1,7 @@ from collections import OrderedDict from django.db import models from django.utils.encoding import force_str +from django.utils.module_loading import import_string from graphene import ( ID, @@ -22,6 +23,7 @@ from graphene.types.json import JSONString from graphene.utils.str_converters import to_camel_case, to_const from graphql import assert_valid_name +from .settings import graphene_settings from .compat import ArrayField, HStoreField, JSONField, RangeField from .fields import DjangoListField, DjangoConnectionField from .utils import import_single_dispatch @@ -68,6 +70,31 @@ def convert_choices_to_named_enum_with_descriptions(name, choices): return Enum(name, list(named_choices), type=EnumWithDescriptionsType) +def generate_enum_name(django_model_meta, field): + if graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME: + # Try and import custom function + custom_func = import_string( + graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME + ) + name = custom_func(field) + elif graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING is True: + name = "{app_label}{object_name}{field_name}Choices".format( + app_label=to_camel_case(django_model_meta.app_label.title()), + object_name=django_model_meta.object_name, + field_name=to_camel_case(field.name.title()), + ) + else: + name = to_camel_case("{}_{}".format(django_model_meta.object_name, field.name)) + return name + + +def convert_choice_field_to_enum(field, name=None): + if name is None: + name = generate_enum_name(field.model._meta, field) + choices = field.choices + return convert_choices_to_named_enum_with_descriptions(name, choices) + + def convert_django_field_with_choices( field, registry=None, convert_choices_to_enum=True ): @@ -77,9 +104,7 @@ def convert_django_field_with_choices( return converted choices = getattr(field, "choices", None) if choices and convert_choices_to_enum: - meta = field.model._meta - name = to_camel_case("{}_{}".format(meta.object_name, field.name)) - enum = convert_choices_to_named_enum_with_descriptions(name, choices) + enum = convert_choice_field_to_enum(field) required = not (field.blank or field.null) converted = enum(description=field.help_text, required=required) else: diff --git a/graphene_django/settings.py b/graphene_django/settings.py index 9a5e8a9..666ad8a 100644 --- a/graphene_django/settings.py +++ b/graphene_django/settings.py @@ -36,6 +36,9 @@ DEFAULTS = { # Max items returned in ConnectionFields / FilterConnectionFields "RELAY_CONNECTION_MAX_LIMIT": 100, "CAMELCASE_ERRORS": False, + # Set to True to enable v3 naming convention for choice field Enum's + "DJANGO_CHOICE_FIELD_ENUM_V3_NAMING": False, + "DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME": None, } if settings.DEBUG: diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index 3790c4a..7f84de3 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -1,4 +1,5 @@ import pytest +from collections import namedtuple from django.db import models from django.utils.translation import ugettext_lazy as _ from graphene import NonNull @@ -10,9 +11,14 @@ from graphene.types.datetime import DateTime, Date, Time from graphene.types.json import JSONString from ..compat import JSONField, ArrayField, HStoreField, RangeField, MissingType -from ..converter import convert_django_field, convert_django_field_with_choices +from ..converter import ( + convert_django_field, + convert_django_field_with_choices, + generate_enum_name, +) from ..registry import Registry from ..types import DjangoObjectType +from ..settings import graphene_settings from .models import Article, Film, FilmDetails, Reporter @@ -325,3 +331,25 @@ def test_should_postgres_range_convert_list(): assert isinstance(field.type, graphene.NonNull) assert isinstance(field.type.of_type, graphene.List) assert field.type.of_type.of_type == graphene.Int + + +def test_generate_enum_name(): + MockDjangoModelMeta = namedtuple("DjangoMeta", ["app_label", "object_name"]) + graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = True + + # Simple case + field = graphene.Field(graphene.String, name="type") + model_meta = MockDjangoModelMeta(app_label="users", object_name="User") + assert generate_enum_name(model_meta, field) == "UsersUserTypeChoices" + + # More complicated multiple work case + field = graphene.Field(graphene.String, name="fizz_buzz") + model_meta = MockDjangoModelMeta( + app_label="some_long_app_name", object_name="SomeObject" + ) + assert ( + generate_enum_name(model_meta, field) + == "SomeLongAppNameSomeObjectFizzBuzzChoices" + ) + + graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = False diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index c32f46c..888521f 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -9,7 +9,9 @@ from graphene import Connection, Field, Interface, ObjectType, Schema, String from graphene.relay import Node from .. import registry +from ..settings import graphene_settings from ..types import DjangoObjectType, DjangoObjectTypeOptions +from ..converter import convert_choice_field_to_enum from .models import Article as ArticleModel from .models import Reporter as ReporterModel @@ -386,6 +388,10 @@ def test_django_objecttype_exclude_fields_exist_on_model(): assert len(record) == 0 +def custom_enum_name(field): + return "CustomEnum{}".format(field.name.title()) + + class TestDjangoObjectType: @pytest.fixture def PetModel(self): @@ -492,3 +498,78 @@ class TestDjangoObjectType: } """ ) + + def test_django_objecttype_convert_choices_enum_naming_collisions(self, PetModel): + graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = True + + class PetModelKind(DjangoObjectType): + class Meta: + model = PetModel + fields = ["id", "kind"] + + class Query(ObjectType): + pet = Field(PetModelKind) + + schema = Schema(query=Query) + + assert str(schema) == dedent( + """\ + schema { + query: Query + } + + type PetModelKind { + id: ID! + kind: TestsPetModelKindChoices! + } + + type Query { + pet: PetModelKind + } + + enum TestsPetModelKindChoices { + CAT + DOG + } + """ + ) + graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = False + + def test_django_objecttype_choices_custom_enum_name(self, PetModel): + graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME = ( + "graphene_django.tests.test_types.custom_enum_name" + ) + + class PetModelKind(DjangoObjectType): + class Meta: + model = PetModel + fields = ["id", "kind"] + + class Query(ObjectType): + pet = Field(PetModelKind) + + schema = Schema(query=Query) + + assert str(schema) == dedent( + """\ + schema { + query: Query + } + + enum CustomEnumKind { + CAT + DOG + } + + type PetModelKind { + id: ID! + kind: CustomEnumKind! + } + + type Query { + pet: PetModelKind + } + """ + ) + + graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME = None From 348fcf37a0e90744321d231a1909ab6a44ecab18 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Fri, 13 Mar 2020 10:04:55 +0000 Subject: [PATCH 03/79] Detect format from output file path (#868) * Detect format from output file path * Fix tests * Add test for exporting graphql file * Add some documentation --- docs/introspection.rst | 14 ++++++ .../management/commands/graphql_schema.py | 26 +++++++++-- graphene_django/tests/test_command.py | 43 +++++++++++++++++-- 3 files changed, 75 insertions(+), 8 deletions(-) diff --git a/docs/introspection.rst b/docs/introspection.rst index dea55bd..2097c30 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -29,6 +29,20 @@ you're ready to use Relay with Graphene GraphQL implementation. The schema file is sorted to create a reproducible canonical representation. +GraphQL SDL Representation +-------------------------- + +The schema can also be exported as a GraphQL SDL file by changing the file +extension : + +.. code:: bash + + ./manage.py graphql_schema --schema tutorial.quickstart.schema --out schema.graphql + +When exporting the schema as a ``.graphql`` file the ``--indent`` option is +ignored. + + Advanced Usage -------------- diff --git a/graphene_django/management/commands/graphql_schema.py b/graphene_django/management/commands/graphql_schema.py index 1e8baf6..751a385 100644 --- a/graphene_django/management/commands/graphql_schema.py +++ b/graphene_django/management/commands/graphql_schema.py @@ -1,3 +1,4 @@ +import os import importlib import json import functools @@ -5,6 +6,7 @@ import functools from django.core.management.base import BaseCommand, CommandError from django.utils import autoreload +from graphql import print_schema from graphene_django.settings import graphene_settings @@ -44,24 +46,40 @@ class CommandArguments(BaseCommand): class Command(CommandArguments): - help = "Dump Graphene schema JSON to file" + help = "Dump Graphene schema as a JSON or GraphQL file" can_import_settings = True - def save_file(self, out, schema_dict, indent): + def save_json_file(self, out, schema_dict, indent): with open(out, "w") as outfile: json.dump(schema_dict, outfile, indent=indent, sort_keys=True) + def save_graphql_file(self, out, schema): + with open(out, "w") as outfile: + outfile.write(print_schema(schema)) + def get_schema(self, schema, out, indent): schema_dict = {"data": schema.introspect()} if out == "-": self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True)) else: - self.save_file(out, schema_dict, indent) + # Determine format + _, file_extension = os.path.splitext(out) + + if file_extension == ".graphql": + self.save_graphql_file(out, schema) + elif file_extension == ".json": + self.save_json_file(out, schema_dict, indent) + else: + raise CommandError( + 'Unrecognised file format "{}"'.format(file_extension) + ) style = getattr(self, "style", None) success = getattr(style, "SUCCESS", lambda x: x) - self.stdout.write(success("Successfully dumped GraphQL schema to %s" % out)) + self.stdout.write( + success("Successfully dumped GraphQL schema to {}".format(out)) + ) def handle(self, *args, **options): options_schema = options.get("schema") diff --git a/graphene_django/tests/test_command.py b/graphene_django/tests/test_command.py index dbabafa..8b0a8e6 100644 --- a/graphene_django/tests/test_command.py +++ b/graphene_django/tests/test_command.py @@ -1,17 +1,21 @@ +from textwrap import dedent + from django.core import management -from mock import patch, mock_open +from mock import mock_open, patch from six import StringIO +from graphene import ObjectType, Schema, String -@patch("graphene_django.management.commands.graphql_schema.Command.save_file") -def test_generate_file_on_call_graphql_schema(savefile_mock, settings): + +@patch("graphene_django.management.commands.graphql_schema.Command.save_json_file") +def test_generate_json_file_on_call_graphql_schema(savefile_mock, settings): out = StringIO() management.call_command("graphql_schema", schema="", stdout=out) assert "Successfully dumped GraphQL schema to schema.json" in out.getvalue() @patch("json.dump") -def test_files_are_canonical(dump_mock): +def test_json_files_are_canonical(dump_mock): open_mock = mock_open() with patch("graphene_django.management.commands.graphql_schema.open", open_mock): management.call_command("graphql_schema", schema="") @@ -25,3 +29,34 @@ def test_files_are_canonical(dump_mock): assert ( dump_mock.call_args[1]["indent"] > 0 ), "output should be pretty-printed by default" + + +def test_generate_graphql_file_on_call_graphql_schema(): + class Query(ObjectType): + hi = String() + + mock_schema = Schema(query=Query) + + open_mock = mock_open() + with patch("graphene_django.management.commands.graphql_schema.open", open_mock): + management.call_command( + "graphql_schema", schema=mock_schema, out="schema.graphql" + ) + + open_mock.assert_called_once() + + handle = open_mock() + assert handle.write.called_once() + + schema_output = handle.write.call_args[0][0] + assert schema_output == dedent( + """\ + schema { + query: Query + } + + type Query { + hi: String + } + """ + ) From c8a56f8857af5d992649d791b27f0139be046563 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Fri, 13 Mar 2020 10:05:35 +0000 Subject: [PATCH 04/79] Allow string references in DjangoListField (#885) * Allow passing string references to DjangoListField * Refactor logic to work with string imports --- graphene_django/fields.py | 21 +++++++++++++-------- graphene_django/tests/test_fields.py | 6 ++++++ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 47b44f6..fb6b98a 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -1,5 +1,6 @@ from functools import partial +import six from django.db.models.query import QuerySet from graphql_relay.connection.arrayconnection import connection_from_list_slice from promise import Promise @@ -19,19 +20,23 @@ class DjangoListField(Field): if isinstance(_type, NonNull): _type = _type.of_type - assert issubclass( - _type, DjangoObjectType - ), "DjangoListField only accepts DjangoObjectType types" - # Django would never return a Set of None vvvvvvv super(DjangoListField, self).__init__(List(NonNull(_type)), *args, **kwargs) + assert issubclass( + self._underlying_type, DjangoObjectType + ), "DjangoListField only accepts DjangoObjectType types" + + @property + def _underlying_type(self): + _type = self._type + while hasattr(_type, "of_type"): + _type = _type.of_type + return _type + @property def model(self): - _type = self.type.of_type - if isinstance(_type, NonNull): - _type = _type.of_type - return _type._meta.model + return self._underlying_type._meta.model @staticmethod def list_resolver(django_object_type, resolver, root, info, **args): diff --git a/graphene_django/tests/test_fields.py b/graphene_django/tests/test_fields.py index f6abf00..8ea1901 100644 --- a/graphene_django/tests/test_fields.py +++ b/graphene_django/tests/test_fields.py @@ -19,6 +19,12 @@ class TestDjangoListField: with pytest.raises(AssertionError): list_field = DjangoListField(TestType) + def test_only_import_paths(self): + list_field = DjangoListField("graphene_django.tests.schema.Human") + from .schema import Human + + assert list_field._type.of_type.of_type is Human + def test_non_null_type(self): class Reporter(DjangoObjectType): class Meta: From 150008aae57f063f8a3c79a0958e46798a600a07 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Fri, 13 Mar 2020 10:13:46 +0000 Subject: [PATCH 05/79] v2.9.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 dbdc201..6574745 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,6 +1,6 @@ from .types import DjangoObjectType from .fields import DjangoConnectionField -__version__ = "2.8.2" +__version__ = "2.9.0" __all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] From cf9f59071ea3570bc7c631cd91723e6a73ceec9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Sat, 14 Mar 2020 13:40:41 +0300 Subject: [PATCH 06/79] Update dev dependencies (#903) --- setup.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/setup.py b/setup.py index a3d0b74..560549a 100644 --- a/setup.py +++ b/setup.py @@ -26,10 +26,10 @@ tests_require = [ dev_requires = [ - "black==19.3b0", - "flake8==3.7.7", - "flake8-black==0.1.0", - "flake8-bugbear==19.3.0", + "black==19.10b0", + "flake8==3.7.9", + "flake8-black==0.1.1", + "flake8-bugbear==20.1.4", ] + tests_require setup( From 0da06d4d54d3e73d43d88534259f55733ab7609b Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Wed, 18 Mar 2020 12:59:32 +0000 Subject: [PATCH 07/79] Update stale.yml --- .github/stale.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/stale.yml b/.github/stale.yml index dab9fb3..d066ca6 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,7 +1,7 @@ # Number of days of inactivity before an issue becomes stale -daysUntilStale: 90 +daysUntilStale: 120 # Number of days of inactivity before a stale issue is closed -daysUntilClose: 14 +daysUntilClose: 30 # Issues with these labels will never be considered stale exemptLabels: - pinned From 63418666d90dd60ec8557e6e0ec35a455452f6f5 Mon Sep 17 00:00:00 2001 From: Roy Segall Date: Mon, 6 Apr 2020 11:15:35 +0300 Subject: [PATCH 08/79] Adding Django filter as an installed app (#899) --- examples/cookbook/cookbook/settings.py | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/cookbook/cookbook/settings.py b/examples/cookbook/cookbook/settings.py index 7eb9d56..3e941b8 100644 --- a/examples/cookbook/cookbook/settings.py +++ b/examples/cookbook/cookbook/settings.py @@ -41,6 +41,7 @@ INSTALLED_APPS = [ "graphene_django", "cookbook.ingredients.apps.IngredientsConfig", "cookbook.recipes.apps.RecipesConfig", + "django_filters", ] MIDDLEWARE = [ From b84f61afab5922f7a330b2f09bdf540b40b44e86 Mon Sep 17 00:00:00 2001 From: fneitzel Date: Mon, 6 Apr 2020 10:58:55 +0200 Subject: [PATCH 09/79] Documentation missing endpoint explanation (#918) * Documentation missing endpoint explanation Add some information about GRAPHQL_URL. Otherwise people run into ERROR 400 problems, if they have a different endpoint. * Update docs/testing.rst Co-Authored-By: Jonathan Kim Co-authored-by: Jonathan Kim --- docs/testing.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/testing.rst b/docs/testing.rst index 031cf6b..473a9ba 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -3,6 +3,8 @@ Testing API calls with django If you want to unittest your API calls derive your test case from the class `GraphQLTestCase`. +Your endpoint is set through the `GRAPHQL_URL` attribute on `GraphQLTestCase`. The default endpoint is `GRAPHQL_URL = "/graphql/"`. + Usage: .. code:: python From 613e1e31f427c1da62e918d8ed20346ac7d382a5 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Tue, 7 Apr 2020 11:05:17 +0100 Subject: [PATCH 10/79] Add slack link --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 2a5237b..7e228c5 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,8 @@ A [Django](https://www.djangoproject.com/) integration for [Graphene](http://gra [conda-image]: https://img.shields.io/conda/vn/conda-forge/graphene-django.svg [conda-url]: https://anaconda.org/conda-forge/graphene-django +[💬 Join the community on Slack](https://join.slack.com/t/graphenetools/shared_invite/enQtOTE2MDQ1NTg4MDM1LTA4Nzk0MGU0NGEwNzUxZGNjNDQ4ZjAwNDJjMjY0OGE1ZDgxZTg4YjM2ZTc4MjE2ZTAzZjE2ZThhZTQzZTkyMmM) + ## Documentation [Visit the documentation to get started!](https://docs.graphene-python.org/projects/django/en/latest/) From 3483428f70efc619b7f138a709486c91ba67abe8 Mon Sep 17 00:00:00 2001 From: Roy Segall Date: Sun, 12 Apr 2020 14:57:11 +0300 Subject: [PATCH 11/79] Adding documentation for installing django-filter (#928) --- docs/filtering.rst | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/filtering.rst b/docs/filtering.rst index 6fe7cab..0d37f46 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -14,8 +14,17 @@ You will need to install it manually, which can be done as follows: .. code:: bash - # You'll need to django-filter + # 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 + + INSTALLED_APPS = [ + # ... + "django_filters", + ] Note: The techniques below are demoed in the `cookbook example app `__. From 9d9a14c36df49a7a569f7f074ae343584f08e865 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sun, 12 Apr 2020 16:18:41 +0100 Subject: [PATCH 12/79] Fix failing tests (#931) * Use proper model * Remove failing test * Add python 3.8 to test list --- .travis.yml | 13 ++++++++++++ graphene_django/forms/tests/test_mutation.py | 21 +++----------------- tox.ini | 4 ++-- 3 files changed, 18 insertions(+), 20 deletions(-) diff --git a/.travis.yml b/.travis.yml index bbeeb80..4b44f60 100644 --- a/.travis.yml +++ b/.travis.yml @@ -62,6 +62,19 @@ jobs: - python: 3.7 env: DJANGO=master + - python: 3.8 + env: DJANGO=1.11 + - python: 3.8 + env: DJANGO=2.0 + - python: 3.8 + env: DJANGO=2.1 + - python: 3.8 + env: DJANGO=2.2 + - python: 3.8 + env: DJANGO=3.0 + - python: 3.8 + env: DJANGO=master + - python: 3.7 env: TOXENV=black,flake8 diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py index f648330..e7dbbdf 100644 --- a/graphene_django/forms/tests/test_mutation.py +++ b/graphene_django/forms/tests/test_mutation.py @@ -5,7 +5,7 @@ from py.test import raises from graphene import ObjectType, Schema, String, Field from graphene_django import DjangoObjectType -from graphene_django.tests.models import Film, FilmDetails, Pet +from graphene_django.tests.models import Film, Pet from ...settings import graphene_settings from ..mutation import DjangoFormMutation, DjangoModelFormMutation @@ -42,12 +42,6 @@ class FilmType(DjangoObjectType): fields = "__all__" -class FilmDetailsType(DjangoObjectType): - class Meta: - model = FilmDetails - fields = "__all__" - - def test_needs_form_class(): with raises(Exception) as exc: @@ -185,23 +179,14 @@ class ModelFormMutationTests(TestCase): self.assertIn("client_mutation_id", PetMutation.Input._meta.fields) self.assertNotIn("id", PetMutation.Input._meta.fields) - def test_return_field_name_is_camelcased(self): - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm - model = FilmDetails - - self.assertEqual(PetMutation._meta.model, FilmDetails) - self.assertEqual(PetMutation._meta.return_field_name, "filmDetails") - def test_custom_return_field_name(self): class PetMutation(DjangoModelFormMutation): class Meta: form_class = PetForm - model = Film + model = Pet return_field_name = "animal" - self.assertEqual(PetMutation._meta.model, Film) + self.assertEqual(PetMutation._meta.model, Pet) self.assertEqual(PetMutation._meta.return_field_name, "animal") self.assertIn("animal", PetMutation._meta.fields) diff --git a/tox.ini b/tox.ini index e7287ff..feeacfd 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = - py{27,35,36,37}-django{111,20,21,22,master}, - py{36,37}-django30, + py{27,35,36,37,38}-django{111,20,21,22,master}, + py{36,37,38}-django30, black,flake8 [travis:env] From 481d3ff35d459587fb8114f40d69aecede3677a1 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sun, 12 Apr 2020 20:01:30 +0100 Subject: [PATCH 13/79] Fix DjangoModelFormMutation (#915) * Fix DjangoModelFormMutation * Try and fix tests * Remove unused form --- graphene_django/forms/mutation.py | 11 +++++ graphene_django/forms/tests/test_mutation.py | 46 ++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/graphene_django/forms/mutation.py b/graphene_django/forms/mutation.py index b7bf049..692f8d5 100644 --- a/graphene_django/forms/mutation.py +++ b/graphene_django/forms/mutation.py @@ -162,6 +162,17 @@ class DjangoModelFormMutation(BaseDjangoFormMutation): _meta=_meta, input_fields=input_fields, **options ) + @classmethod + def mutate_and_get_payload(cls, root, info, **input): + form = cls.get_form(root, info, **input) + + if form.is_valid(): + return cls.perform_mutate(form, info) + else: + errors = ErrorType.from_errors(form.errors) + + return cls(errors=errors) + @classmethod def perform_mutate(cls, form, info): obj = form.save() diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py index e7dbbdf..093f398 100644 --- a/graphene_django/forms/tests/test_mutation.py +++ b/graphene_django/forms/tests/test_mutation.py @@ -29,6 +29,12 @@ class PetForm(forms.ModelForm): model = Pet fields = "__all__" + def clean_age(self): + age = self.cleaned_data["age"] + if age >= 99: + raise ValidationError("Too old") + return age + class PetType(DjangoObjectType): class Meta: @@ -243,6 +249,10 @@ class ModelFormMutationTests(TestCase): name age } + errors { + field + messages + } } } """ @@ -255,6 +265,42 @@ class ModelFormMutationTests(TestCase): self.assertEqual(pet.name, "Mia") self.assertEqual(pet.age, 10) + def test_model_form_mutation_invalid_input(self): + class PetMutation(DjangoModelFormMutation): + pet = Field(PetType) + + class Meta: + form_class = PetForm + + class Mutation(ObjectType): + pet_mutation = PetMutation.Field() + + schema = Schema(query=MockQuery, mutation=Mutation) + + result = schema.execute( + """ mutation PetMutation { + petMutation(input: { name: "Mia", age: 99 }) { + pet { + name + age + } + errors { + field + messages + } + } + } + """ + ) + self.assertIs(result.errors, None) + self.assertEqual(result.data["petMutation"]["pet"], None) + self.assertEqual( + result.data["petMutation"]["errors"], + [{"field": "age", "messages": ["Too old"],}], + ) + + self.assertEqual(Pet.objects.count(), 0) + def test_model_form_mutation_mutate_invalid_form(self): class PetMutation(DjangoModelFormMutation): class Meta: From e1cfc0a80b25d1625650104863c16d7b83c5054c Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sun, 12 Apr 2020 20:01:56 +0100 Subject: [PATCH 14/79] v2.9.1 --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 6574745..38f8d8a 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,6 +1,6 @@ from .types import DjangoObjectType from .fields import DjangoConnectionField -__version__ = "2.9.0" +__version__ = "2.9.1" __all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] From cfc8fea7f52fa625c8b3e110ac16953a5ea7208c Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Mon, 13 Apr 2020 11:53:06 +0100 Subject: [PATCH 15/79] Update README.md --- README.md | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7e228c5..92b5b65 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,3 @@ -Please read [UPGRADE-v2.0.md](https://github.com/graphql-python/graphene/blob/master/UPGRADE-v2.0.md) to learn how to upgrade to Graphene `2.0`. - ---- - # ![Graphene Logo](http://graphene-python.org/favicon.png) Graphene-Django @@ -12,7 +8,7 @@ A [Django](https://www.djangoproject.com/) integration for [Graphene](http://gra [![Anaconda-Server Badge][conda-image]][conda-url] [![coveralls][coveralls-image]][coveralls-url] -[travis-image]: https://travis-ci.org/graphql-python/graphene-django.svg?style=flat +[travis-image]: https://travis-ci.org/graphql-python/graphene-django.svg?branch=master&style=flat [travis-url]: https://travis-ci.org/graphql-python/graphene-django [pypi-image]: https://img.shields.io/pypi/v/graphene-django.svg?style=flat [pypi-url]: https://pypi.org/project/graphene-django/ @@ -94,7 +90,7 @@ class Query(graphene.ObjectType): schema = graphene.Schema(query=Query) ``` -Then you can simply query the schema: +Then you can query the schema: ```python query = ''' From fba6de41dd698ae2b944d368de7b3f961288fc6f Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Mon, 13 Apr 2020 11:54:17 +0100 Subject: [PATCH 16/79] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 92b5b65..8605065 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ A [Django](https://www.djangoproject.com/) integration for [Graphene](http://gra [travis-url]: https://travis-ci.org/graphql-python/graphene-django [pypi-image]: https://img.shields.io/pypi/v/graphene-django.svg?style=flat [pypi-url]: https://pypi.org/project/graphene-django/ -[coveralls-image]: https://coveralls.io/repos/graphql-python/graphene-django/badge.svg?branch=master&service=github +[coveralls-image]: https://coveralls.io/repos/github/graphql-python/graphene-django/badge.svg?branch=master [coveralls-url]: https://coveralls.io/github/graphql-python/graphene-django?branch=master [conda-image]: https://img.shields.io/conda/vn/conda-forge/graphene-django.svg [conda-url]: https://anaconda.org/conda-forge/graphene-django From 23b6419b428a0f1b4bd3a8124e49d899b86800a7 Mon Sep 17 00:00:00 2001 From: Sam Millar Date: Sun, 19 Apr 2020 20:42:00 +0100 Subject: [PATCH 17/79] Disable system checks for graphql_schema management command (#939) --- graphene_django/management/commands/graphql_schema.py | 1 + 1 file changed, 1 insertion(+) diff --git a/graphene_django/management/commands/graphql_schema.py b/graphene_django/management/commands/graphql_schema.py index 751a385..dcef73c 100644 --- a/graphene_django/management/commands/graphql_schema.py +++ b/graphene_django/management/commands/graphql_schema.py @@ -48,6 +48,7 @@ class CommandArguments(BaseCommand): class Command(CommandArguments): help = "Dump Graphene schema as a JSON or GraphQL file" can_import_settings = True + requires_system_checks = False def save_json_file(self, out, schema_dict, indent): with open(out, "w") as outfile: From dc5c97149899a272d95aee98b66e072314cd5a5b Mon Sep 17 00:00:00 2001 From: Noelle Leigh Date: Sun, 19 Apr 2020 16:11:33 -0400 Subject: [PATCH 18/79] Switch `operation_name` to `operationName` in GraphQLTestCase (#936) * Add op_name test * Replace "operation_name" with "operationName" * Improve test comments * Add method for Python 2.7 --- graphene_django/tests/test_utils.py | 32 +++++++++++++++++++++++++++-- graphene_django/utils/testing.py | 6 +++--- 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/graphene_django/tests/test_utils.py b/graphene_django/tests/test_utils.py index 55cfd4f..c0d376b 100644 --- a/graphene_django/tests/test_utils.py +++ b/graphene_django/tests/test_utils.py @@ -1,6 +1,10 @@ -from django.utils.translation import gettext_lazy +import json -from ..utils import camelize, get_model_fields +import pytest +from django.utils.translation import gettext_lazy +from mock import patch + +from ..utils import camelize, get_model_fields, GraphQLTestCase from .models import Film, Reporter @@ -30,3 +34,27 @@ def test_camelize(): "valueA": "value_b" } assert camelize({0: {"field_a": ["errors"]}}) == {0: {"fieldA": ["errors"]}} + + +@pytest.mark.django_db +@patch("graphene_django.utils.testing.Client.post") +def test_graphql_test_case_op_name(post_mock): + """ + Test that `GraphQLTestCase.query()`'s `op_name` argument produces an `operationName` field. + """ + + class TestClass(GraphQLTestCase): + GRAPHQL_SCHEMA = True + + def runTest(self): + pass + + tc = TestClass() + 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 + assert ( + "operationName", + "QueryName", + ) in body.items(), "Field 'operationName' is not present in the final request." diff --git a/graphene_django/utils/testing.py b/graphene_django/utils/testing.py index 8a9b994..0f68a51 100644 --- a/graphene_django/utils/testing.py +++ b/graphene_django/utils/testing.py @@ -32,20 +32,20 @@ class GraphQLTestCase(TestCase): supply the op_name. For annon queries ("{ ... }"), should be None (default). input_data (dict) - If provided, the $input variable in GraphQL will be set - to this value. If both ``input_data`` and ``variables``, + to this value. If both ``input_data`` and ``variables``, are provided, the ``input`` field in the ``variables`` dict will be overwritten with this value. variables (dict) - If provided, the "variables" field in GraphQL will be set to this value. headers (dict) - If provided, the headers in POST request to GRAPHQL_URL - will be set to this value. + will be set to this value. Returns: Response object from client """ body = {"query": query} if op_name: - body["operation_name"] = op_name + body["operationName"] = op_name if variables: body["variables"] = variables if input_data: From b9f0e4f9aef32bbc238a880012c86aa0b6b32134 Mon Sep 17 00:00:00 2001 From: Jean-Louis Fuchs Date: Mon, 20 Apr 2020 14:23:20 +0200 Subject: [PATCH 19/79] Make tests order independent (#932) * Reset the global registry after each test (teardown) * Create a settings fixtures that returns graphene_settings and resets the graphene_settings after use (teardown) * Convert test_mutation tests from unittests.TestCase to pytest * Convert test_mutation PetType to a pet_type fixtures that reregisters the type --- graphene_django/conftest.py | 18 + graphene_django/debug/tests/test_query.py | 7 - graphene_django/filter/tests/test_fields.py | 3 - graphene_django/forms/tests/test_mutation.py | 430 +++++++++--------- .../tests/test_multiple_model_serializers.py | 7 +- .../rest_framework/tests/test_mutation.py | 16 +- graphene_django/tests/test_command.py | 2 +- graphene_django/tests/test_converter.py | 15 +- graphene_django/tests/test_fields.py | 1 - graphene_django/tests/test_query.py | 28 +- graphene_django/tests/test_types.py | 16 +- 11 files changed, 259 insertions(+), 284 deletions(-) create mode 100644 graphene_django/conftest.py diff --git a/graphene_django/conftest.py b/graphene_django/conftest.py new file mode 100644 index 0000000..509a84c --- /dev/null +++ b/graphene_django/conftest.py @@ -0,0 +1,18 @@ +import pytest + +from graphene_django.settings import graphene_settings as gsettings + +from .registry import reset_global_registry + + +@pytest.fixture(autouse=True) +def reset_registry_fixture(db): + yield None + reset_global_registry() + + +@pytest.fixture() +def graphene_settings(): + settings = dict(gsettings.__dict__) + yield gsettings + gsettings.__dict__ = settings diff --git a/graphene_django/debug/tests/test_query.py b/graphene_django/debug/tests/test_query.py index db8f275..7226f9b 100644 --- a/graphene_django/debug/tests/test_query.py +++ b/graphene_django/debug/tests/test_query.py @@ -1,5 +1,3 @@ -import pytest - import graphene from graphene.relay import Node from graphene_django import DjangoConnectionField, DjangoObjectType @@ -13,11 +11,6 @@ class context(object): pass -# from examples.starwars_django.models import Character - -pytestmark = pytest.mark.django_db - - def test_should_query_field(): r1 = Reporter(last_name="ABA") r1.save() diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index a0f7d96..166d806 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -35,9 +35,6 @@ else: ) ) -pytestmark.append(pytest.mark.django_db) - - if DJANGO_FILTER_INSTALLED: class ArticleNode(DjangoObjectType): diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py index 093f398..a455a0a 100644 --- a/graphene_django/forms/tests/test_mutation.py +++ b/graphene_django/forms/tests/test_mutation.py @@ -1,16 +1,25 @@ +import pytest from django import forms -from django.test import TestCase from django.core.exceptions import ValidationError from py.test import raises -from graphene import ObjectType, Schema, String, Field +from graphene import Field, ObjectType, Schema, String from graphene_django import DjangoObjectType -from graphene_django.tests.models import Film, Pet +from graphene_django.tests.models import Pet -from ...settings import graphene_settings from ..mutation import DjangoFormMutation, DjangoModelFormMutation +@pytest.fixture() +def pet_type(): + class PetType(DjangoObjectType): + class Meta: + model = Pet + fields = "__all__" + + return PetType + + class MyForm(forms.Form): text = forms.CharField() @@ -36,18 +45,6 @@ class PetForm(forms.ModelForm): return age -class PetType(DjangoObjectType): - class Meta: - model = Pet - fields = "__all__" - - -class FilmType(DjangoObjectType): - class Meta: - model = Film - fields = "__all__" - - def test_needs_form_class(): with raises(Exception) as exc: @@ -73,7 +70,7 @@ def test_has_input_fields(): assert "text" in MyMutation.Input._meta.fields -def test_mutation_error_camelcased(): +def test_mutation_error_camelcased(pet_type, graphene_settings): class ExtraPetForm(PetForm): test_field = forms.CharField(required=True) @@ -86,234 +83,237 @@ def test_mutation_error_camelcased(): graphene_settings.CAMELCASE_ERRORS = True result = PetMutation.mutate_and_get_payload(None, None) assert {f.field for f in result.errors} == {"name", "age", "testField"} - graphene_settings.CAMELCASE_ERRORS = False class MockQuery(ObjectType): a = String() -class FormMutationTests(TestCase): - def test_form_invalid_form(self): - class MyMutation(DjangoFormMutation): - class Meta: - form_class = MyForm +def test_form_invalid_form(): + class MyMutation(DjangoFormMutation): + class Meta: + form_class = MyForm - class Mutation(ObjectType): - my_mutation = MyMutation.Field() + class Mutation(ObjectType): + my_mutation = MyMutation.Field() - schema = Schema(query=MockQuery, mutation=Mutation) + schema = Schema(query=MockQuery, mutation=Mutation) - result = schema.execute( - """ mutation MyMutation { - myMutation(input: { text: "INVALID_INPUT" }) { - errors { - field - messages - } - text + result = schema.execute( + """ mutation MyMutation { + myMutation(input: { text: "INVALID_INPUT" }) { + errors { + field + messages + } + text + } + } + """ + ) + + assert result.errors is None + assert result.data["myMutation"]["errors"] == [ + {"field": "text", "messages": ["Invalid input"]} + ] + + +def test_form_valid_input(): + class MyMutation(DjangoFormMutation): + class Meta: + form_class = MyForm + + class Mutation(ObjectType): + my_mutation = MyMutation.Field() + + schema = Schema(query=MockQuery, mutation=Mutation) + + result = schema.execute( + """ mutation MyMutation { + myMutation(input: { text: "VALID_INPUT" }) { + errors { + field + messages + } + text + } + } + """ + ) + + assert result.errors is None + assert result.data["myMutation"]["errors"] == [] + assert result.data["myMutation"]["text"] == "VALID_INPUT" + + +def test_default_meta_fields(pet_type): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm + + assert PetMutation._meta.model is Pet + assert PetMutation._meta.return_field_name == "pet" + assert "pet" in PetMutation._meta.fields + + +def test_default_input_meta_fields(pet_type): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm + + assert PetMutation._meta.model is Pet + assert PetMutation._meta.return_field_name == "pet" + assert "name" in PetMutation.Input._meta.fields + assert "client_mutation_id" in PetMutation.Input._meta.fields + assert "id" in PetMutation.Input._meta.fields + + +def test_exclude_fields_input_meta_fields(pet_type): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm + exclude_fields = ["id"] + + assert PetMutation._meta.model is Pet + assert PetMutation._meta.return_field_name == "pet" + assert "name" in PetMutation.Input._meta.fields + assert "age" in PetMutation.Input._meta.fields + assert "client_mutation_id" in PetMutation.Input._meta.fields + assert "id" not in PetMutation.Input._meta.fields + + +def test_custom_return_field_name(pet_type): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm + model = Pet + return_field_name = "animal" + + assert PetMutation._meta.model is Pet + assert PetMutation._meta.return_field_name == "animal" + assert "animal" in PetMutation._meta.fields + + +def test_model_form_mutation_mutate_existing(pet_type): + class PetMutation(DjangoModelFormMutation): + pet = Field(pet_type) + + class Meta: + form_class = PetForm + + class Mutation(ObjectType): + pet_mutation = PetMutation.Field() + + schema = Schema(query=MockQuery, mutation=Mutation) + + pet = Pet.objects.create(name="Axel", age=10) + + result = schema.execute( + """ mutation PetMutation($pk: ID!) { + petMutation(input: { id: $pk, name: "Mia", age: 10 }) { + pet { + name + age } } - """ - ) + } + """, + variable_values={"pk": pet.pk}, + ) - self.assertIs(result.errors, None) - self.assertEqual( - result.data["myMutation"]["errors"], - [{"field": "text", "messages": ["Invalid input"]}], - ) + assert result.errors is None + assert result.data["petMutation"]["pet"] == {"name": "Mia", "age": 10} - def test_form_valid_input(self): - class MyMutation(DjangoFormMutation): - class Meta: - form_class = MyForm + assert Pet.objects.count() == 1 + pet.refresh_from_db() + assert pet.name == "Mia" - class Mutation(ObjectType): - my_mutation = MyMutation.Field() - schema = Schema(query=MockQuery, mutation=Mutation) +def test_model_form_mutation_creates_new(pet_type): + class PetMutation(DjangoModelFormMutation): + pet = Field(pet_type) - result = schema.execute( - """ mutation MyMutation { - myMutation(input: { text: "VALID_INPUT" }) { - errors { - field - messages - } - text + class Meta: + form_class = PetForm + + class Mutation(ObjectType): + pet_mutation = PetMutation.Field() + + schema = Schema(query=MockQuery, mutation=Mutation) + + result = schema.execute( + """ mutation PetMutation { + petMutation(input: { name: "Mia", age: 10 }) { + pet { + name + age + } + errors { + field + messages } } - """ - ) + } + """ + ) + assert result.errors is None + assert result.data["petMutation"]["pet"] == {"name": "Mia", "age": 10} - self.assertIs(result.errors, None) - self.assertEqual(result.data["myMutation"]["errors"], []) - self.assertEqual(result.data["myMutation"]["text"], "VALID_INPUT") + assert Pet.objects.count() == 1 + pet = Pet.objects.get() + assert pet.name == "Mia" + assert pet.age == 10 -class ModelFormMutationTests(TestCase): - def test_default_meta_fields(self): - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm +def test_model_form_mutation_invalid_input(pet_type): + class PetMutation(DjangoModelFormMutation): + pet = Field(pet_type) - self.assertEqual(PetMutation._meta.model, Pet) - self.assertEqual(PetMutation._meta.return_field_name, "pet") - self.assertIn("pet", PetMutation._meta.fields) + class Meta: + form_class = PetForm - def test_default_input_meta_fields(self): - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm + class Mutation(ObjectType): + pet_mutation = PetMutation.Field() - self.assertEqual(PetMutation._meta.model, Pet) - self.assertEqual(PetMutation._meta.return_field_name, "pet") - self.assertIn("name", PetMutation.Input._meta.fields) - self.assertIn("client_mutation_id", PetMutation.Input._meta.fields) - self.assertIn("id", PetMutation.Input._meta.fields) + schema = Schema(query=MockQuery, mutation=Mutation) - def test_exclude_fields_input_meta_fields(self): - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm - exclude_fields = ["id"] - - self.assertEqual(PetMutation._meta.model, Pet) - self.assertEqual(PetMutation._meta.return_field_name, "pet") - self.assertIn("name", PetMutation.Input._meta.fields) - self.assertIn("age", PetMutation.Input._meta.fields) - self.assertIn("client_mutation_id", PetMutation.Input._meta.fields) - self.assertNotIn("id", PetMutation.Input._meta.fields) - - def test_custom_return_field_name(self): - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm - model = Pet - return_field_name = "animal" - - self.assertEqual(PetMutation._meta.model, Pet) - self.assertEqual(PetMutation._meta.return_field_name, "animal") - self.assertIn("animal", PetMutation._meta.fields) - - def test_model_form_mutation_mutate_existing(self): - class PetMutation(DjangoModelFormMutation): - pet = Field(PetType) - - class Meta: - form_class = PetForm - - class Mutation(ObjectType): - pet_mutation = PetMutation.Field() - - schema = Schema(query=MockQuery, mutation=Mutation) - - pet = Pet.objects.create(name="Axel", age=10) - - result = schema.execute( - """ mutation PetMutation($pk: ID!) { - petMutation(input: { id: $pk, name: "Mia", age: 10 }) { - pet { - name - age - } + result = schema.execute( + """ mutation PetMutation { + petMutation(input: { name: "Mia", age: 99 }) { + pet { + name + age + } + errors { + field + messages } } - """, - variable_values={"pk": pet.pk}, - ) + } + """ + ) + assert result.errors is None + assert result.data["petMutation"]["pet"] is None + assert result.data["petMutation"]["errors"] == [ + {"field": "age", "messages": ["Too old"]} + ] - self.assertIs(result.errors, None) - self.assertEqual(result.data["petMutation"]["pet"], {"name": "Mia", "age": 10}) + assert Pet.objects.count() == 0 - self.assertEqual(Pet.objects.count(), 1) - pet.refresh_from_db() - self.assertEqual(pet.name, "Mia") - def test_model_form_mutation_creates_new(self): - class PetMutation(DjangoModelFormMutation): - pet = Field(PetType) +def test_model_form_mutation_mutate_invalid_form(pet_type): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm - class Meta: - form_class = PetForm + result = PetMutation.mutate_and_get_payload(None, None) - class Mutation(ObjectType): - pet_mutation = PetMutation.Field() + # A pet was not created + Pet.objects.count() == 0 - schema = Schema(query=MockQuery, mutation=Mutation) - - result = schema.execute( - """ mutation PetMutation { - petMutation(input: { name: "Mia", age: 10 }) { - pet { - name - age - } - errors { - field - messages - } - } - } - """ - ) - self.assertIs(result.errors, None) - self.assertEqual(result.data["petMutation"]["pet"], {"name": "Mia", "age": 10}) - - self.assertEqual(Pet.objects.count(), 1) - pet = Pet.objects.get() - self.assertEqual(pet.name, "Mia") - self.assertEqual(pet.age, 10) - - def test_model_form_mutation_invalid_input(self): - class PetMutation(DjangoModelFormMutation): - pet = Field(PetType) - - class Meta: - form_class = PetForm - - class Mutation(ObjectType): - pet_mutation = PetMutation.Field() - - schema = Schema(query=MockQuery, mutation=Mutation) - - result = schema.execute( - """ mutation PetMutation { - petMutation(input: { name: "Mia", age: 99 }) { - pet { - name - age - } - errors { - field - messages - } - } - } - """ - ) - self.assertIs(result.errors, None) - self.assertEqual(result.data["petMutation"]["pet"], None) - self.assertEqual( - result.data["petMutation"]["errors"], - [{"field": "age", "messages": ["Too old"],}], - ) - - self.assertEqual(Pet.objects.count(), 0) - - def test_model_form_mutation_mutate_invalid_form(self): - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm - - result = PetMutation.mutate_and_get_payload(None, None) - - # A pet was not created - self.assertEqual(Pet.objects.count(), 0) - - fields_w_error = [e.field for e in result.errors] - self.assertEqual(len(result.errors), 2) - self.assertIn("name", fields_w_error) - self.assertEqual(result.errors[0].messages, ["This field is required."]) - self.assertIn("age", fields_w_error) - self.assertEqual(result.errors[1].messages, ["This field is required."]) + fields_w_error = [e.field for e in result.errors] + assert len(result.errors) == 2 + assert result.errors[0].messages == ["This field is required."] + assert result.errors[1].messages == ["This field is required."] + assert "age" in fields_w_error + assert "name" in fields_w_error diff --git a/graphene_django/rest_framework/tests/test_multiple_model_serializers.py b/graphene_django/rest_framework/tests/test_multiple_model_serializers.py index c1f4626..1676b62 100644 --- a/graphene_django/rest_framework/tests/test_multiple_model_serializers.py +++ b/graphene_django/rest_framework/tests/test_multiple_model_serializers.py @@ -1,14 +1,11 @@ -import graphene -import pytest from django.db import models -from graphene import Schema from rest_framework import serializers +import graphene +from graphene import Schema from graphene_django import DjangoObjectType from graphene_django.rest_framework.mutation import SerializerMutation -pytestmark = pytest.mark.django_db - class MyFakeChildModel(models.Model): name = models.CharField(max_length=50) diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py index 5bf3bc1..1599fea 100644 --- a/graphene_django/rest_framework/tests/test_mutation.py +++ b/graphene_django/rest_framework/tests/test_mutation.py @@ -1,14 +1,13 @@ import datetime -from py.test import mark, raises +from py.test import raises from rest_framework import serializers from graphene import Field, ResolveInfo from graphene.types.inputobjecttype import InputObjectType -from ...settings import graphene_settings from ...types import DjangoObjectType -from ..models import MyFakeModel, MyFakeModelWithPassword, MyFakeModelWithDate +from ..models import MyFakeModel, MyFakeModelWithDate, MyFakeModelWithPassword from ..mutation import SerializerMutation @@ -99,7 +98,6 @@ def test_exclude_fields(): assert "created" not in MyMutation.Input._meta.fields -@mark.django_db def test_write_only_field(): class WriteOnlyFieldModelSerializer(serializers.ModelSerializer): password = serializers.CharField(write_only=True) @@ -122,7 +120,6 @@ def test_write_only_field(): ), "'password' is write_only field and shouldn't be visible" -@mark.django_db def test_write_only_field_using_extra_kwargs(): class WriteOnlyFieldModelSerializer(serializers.ModelSerializer): class Meta: @@ -144,7 +141,6 @@ def test_write_only_field_using_extra_kwargs(): ), "'password' is write_only field and shouldn't be visible" -@mark.django_db def test_read_only_fields(): class ReadOnlyFieldModelSerializer(serializers.ModelSerializer): cool_name = serializers.CharField(read_only=True) @@ -194,7 +190,6 @@ def test_mutate_and_get_payload_success(): assert result.errors is None -@mark.django_db def test_model_add_mutate_and_get_payload_success(): result = MyModelMutation.mutate_and_get_payload( None, mock_info(), **{"cool_name": "Narf"} @@ -204,7 +199,6 @@ def test_model_add_mutate_and_get_payload_success(): assert isinstance(result.created, datetime.datetime) -@mark.django_db def test_model_update_mutate_and_get_payload_success(): instance = MyFakeModel.objects.create(cool_name="Narf") result = MyModelMutation.mutate_and_get_payload( @@ -214,7 +208,6 @@ def test_model_update_mutate_and_get_payload_success(): assert result.cool_name == "New Narf" -@mark.django_db def test_model_partial_update_mutate_and_get_payload_success(): instance = MyFakeModel.objects.create(cool_name="Narf") result = MyModelMutation.mutate_and_get_payload( @@ -224,7 +217,6 @@ def test_model_partial_update_mutate_and_get_payload_success(): assert result.cool_name == "Narf" -@mark.django_db def test_model_invalid_update_mutate_and_get_payload_success(): class InvalidModelMutation(SerializerMutation): class Meta: @@ -239,7 +231,6 @@ def test_model_invalid_update_mutate_and_get_payload_success(): assert '"id" required' in str(exc.value) -@mark.django_db def test_perform_mutate_success(): class MyMethodMutation(SerializerMutation): class Meta: @@ -272,11 +263,10 @@ def test_model_mutate_and_get_payload_error(): assert len(result.errors) > 0 -def test_mutation_error_camelcased(): +def test_mutation_error_camelcased(graphene_settings): graphene_settings.CAMELCASE_ERRORS = True result = MyModelMutation.mutate_and_get_payload(None, mock_info(), **{}) assert result.errors[0].field == "coolName" - graphene_settings.CAMELCASE_ERRORS = False def test_invalid_serializer_operations(): diff --git a/graphene_django/tests/test_command.py b/graphene_django/tests/test_command.py index 8b0a8e6..297e461 100644 --- a/graphene_django/tests/test_command.py +++ b/graphene_django/tests/test_command.py @@ -8,7 +8,7 @@ from graphene import ObjectType, Schema, String @patch("graphene_django.management.commands.graphql_schema.Command.save_json_file") -def test_generate_json_file_on_call_graphql_schema(savefile_mock, settings): +def test_generate_json_file_on_call_graphql_schema(savefile_mock): out = StringIO() management.call_command("graphql_schema", schema="", stdout=out) assert "Successfully dumped GraphQL schema to schema.json" in out.getvalue() diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index 7f84de3..6227326 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -1,16 +1,17 @@ -import pytest from collections import namedtuple + +import pytest from django.db import models from django.utils.translation import ugettext_lazy as _ -from graphene import NonNull from py.test import raises import graphene +from graphene import NonNull from graphene.relay import ConnectionField, Node -from graphene.types.datetime import DateTime, Date, Time +from graphene.types.datetime import Date, DateTime, Time from graphene.types.json import JSONString -from ..compat import JSONField, ArrayField, HStoreField, RangeField, MissingType +from ..compat import ArrayField, HStoreField, JSONField, MissingType, RangeField from ..converter import ( convert_django_field, convert_django_field_with_choices, @@ -18,10 +19,8 @@ from ..converter import ( ) from ..registry import Registry from ..types import DjangoObjectType -from ..settings import graphene_settings from .models import Article, Film, FilmDetails, Reporter - # from graphene.core.types.custom_scalars import DateTime, Time, JSONString @@ -333,7 +332,7 @@ def test_should_postgres_range_convert_list(): assert field.type.of_type.of_type == graphene.Int -def test_generate_enum_name(): +def test_generate_enum_name(graphene_settings): MockDjangoModelMeta = namedtuple("DjangoMeta", ["app_label", "object_name"]) graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = True @@ -351,5 +350,3 @@ def test_generate_enum_name(): generate_enum_name(model_meta, field) == "SomeLongAppNameSomeObjectFizzBuzzChoices" ) - - graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = False diff --git a/graphene_django/tests/test_fields.py b/graphene_django/tests/test_fields.py index 8ea1901..67b3a35 100644 --- a/graphene_django/tests/test_fields.py +++ b/graphene_django/tests/test_fields.py @@ -10,7 +10,6 @@ from .models import Article as ArticleModel from .models import Reporter as ReporterModel -@pytest.mark.django_db class TestDjangoListField: def test_only_django_object_types(self): class TestType(ObjectType): diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 95db2d1..698ca23 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1,25 +1,20 @@ -import base64 import datetime import pytest from django.db import models +from django.db.models import Q from django.utils.functional import SimpleLazyObject +from graphql_relay import to_global_id from py.test import raises -from django.db.models import Q - -from graphql_relay import to_global_id import graphene from graphene.relay import Node -from ..utils import DJANGO_FILTER_INSTALLED -from ..compat import MissingType, JSONField +from ..compat import JSONField, MissingType from ..fields import DjangoConnectionField from ..types import DjangoObjectType -from ..settings import graphene_settings -from .models import Article, CNNReporter, Reporter, Film, FilmDetails - -pytestmark = pytest.mark.django_db +from ..utils import DJANGO_FILTER_INSTALLED +from .models import Article, CNNReporter, Film, FilmDetails, Reporter def test_should_query_only_fields(): @@ -147,9 +142,6 @@ def test_should_query_postgres_fields(): def test_should_node(): - # reset_global_registry() - # Node._meta.registry = get_global_registry() - class ReporterNode(DjangoObjectType): class Meta: model = Reporter @@ -588,7 +580,7 @@ def test_should_query_node_multiple_filtering(): assert result.data == expected -def test_should_enforce_first_or_last(): +def test_should_enforce_first_or_last(graphene_settings): graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST = True class ReporterType(DjangoObjectType): @@ -627,7 +619,7 @@ def test_should_enforce_first_or_last(): assert result.data == expected -def test_should_error_if_first_is_greater_than_max(): +def test_should_error_if_first_is_greater_than_max(graphene_settings): graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 100 class ReporterType(DjangoObjectType): @@ -667,10 +659,8 @@ def test_should_error_if_first_is_greater_than_max(): ) assert result.data == expected - graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST = False - -def test_should_error_if_last_is_greater_than_max(): +def test_should_error_if_last_is_greater_than_max(graphene_settings): graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 100 class ReporterType(DjangoObjectType): @@ -710,8 +700,6 @@ def test_should_error_if_last_is_greater_than_max(): ) assert result.data == expected - graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST = False - def test_should_query_promise_connectionfields(): from promise import Promise diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 888521f..9b3ceb6 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -9,14 +9,10 @@ from graphene import Connection, Field, Interface, ObjectType, Schema, String from graphene.relay import Node from .. import registry -from ..settings import graphene_settings from ..types import DjangoObjectType, DjangoObjectTypeOptions -from ..converter import convert_choice_field_to_enum from .models import Article as ArticleModel from .models import Reporter as ReporterModel -registry.reset_global_registry() - class Reporter(DjangoObjectType): """Reporter description""" @@ -198,7 +194,6 @@ type RootQuery { def with_local_registry(func): def inner(*args, **kwargs): old = registry.get_global_registry() - registry.reset_global_registry() try: retval = func(*args, **kwargs) except Exception as e: @@ -499,7 +494,9 @@ class TestDjangoObjectType: """ ) - def test_django_objecttype_convert_choices_enum_naming_collisions(self, PetModel): + def test_django_objecttype_convert_choices_enum_naming_collisions( + self, PetModel, graphene_settings + ): graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = True class PetModelKind(DjangoObjectType): @@ -533,9 +530,10 @@ class TestDjangoObjectType: } """ ) - graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = False - def test_django_objecttype_choices_custom_enum_name(self, PetModel): + def test_django_objecttype_choices_custom_enum_name( + self, PetModel, graphene_settings + ): graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME = ( "graphene_django.tests.test_types.custom_enum_name" ) @@ -571,5 +569,3 @@ class TestDjangoObjectType: } """ ) - - graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME = None From b8d8508d1f9a7cb8a25150645e23a1e60c6a6837 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Fri, 1 May 2020 14:04:36 +0100 Subject: [PATCH 20/79] Add GitHub actions (#947) --- .github/workflows/deploy.yml | 25 ++++++++++ .github/workflows/lint.yml | 22 +++++++++ .github/workflows/tests.yml | 31 ++++++++++++ .travis.yml | 92 ------------------------------------ tox.ini | 15 ++++-- 5 files changed, 89 insertions(+), 96 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/tests.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..5dd418e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,25 @@ +name: 🚀 Deploy to PyPI + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.8 + uses: actions/setup-python@v1 + with: + python-version: 3.8 + - name: Build wheel and source tarball + run: | + python setup.py sdist bdist_wheel + - name: Publish a Python distribution to PyPI + uses: pypa/gh-action-pypi-publish@v1.1.0 + with: + user: __token__ + password: ${{ secrets.pypi_password }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..20cf7fb --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,22 @@ +name: Lint + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.8 + uses: actions/setup-python@v1 + with: + python-version: 3.8 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox + - name: Run lint 💅 + run: tox + env: + TOXENV: flake8 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..37453f0 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,31 @@ +name: Tests + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + max-parallel: 4 + matrix: + django: ["1.11", "2.2", "3.0"] + python-version: ["3.6", "3.7", "3.8"] + include: + - django: "1.11" + python-version: "2.7" + + steps: + - uses: actions/checkout@v1 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - name: Test with tox + run: tox + env: + DJANGO: ${{ matrix.django }} + TOXENV: ${{ matrix.toxenv }} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 4b44f60..0000000 --- a/.travis.yml +++ /dev/null @@ -1,92 +0,0 @@ -language: python -cache: pip -dist: xenial - -install: - - pip install tox tox-travis - -script: - - tox - -after_success: - - pip install coveralls - - coveralls - -stages: - - test - - name: deploy - if: tag IS present - -jobs: - fast_finish: true - - allow_failures: - - env: DJANGO=master - - include: - - python: 2.7 - env: DJANGO=1.11 - - - python: 3.5 - env: DJANGO=1.11 - - python: 3.5 - env: DJANGO=2.0 - - python: 3.5 - env: DJANGO=2.1 - - python: 3.5 - env: DJANGO=2.2 - - - python: 3.6 - env: DJANGO=1.11 - - python: 3.6 - env: DJANGO=2.0 - - python: 3.6 - env: DJANGO=2.1 - - python: 3.6 - env: DJANGO=2.2 - - python: 3.6 - env: DJANGO=3.0 - - python: 3.6 - env: DJANGO=master - - - python: 3.7 - env: DJANGO=1.11 - - python: 3.7 - env: DJANGO=2.0 - - python: 3.7 - env: DJANGO=2.1 - - python: 3.7 - env: DJANGO=2.2 - - python: 3.7 - env: DJANGO=3.0 - - python: 3.7 - env: DJANGO=master - - - python: 3.8 - env: DJANGO=1.11 - - python: 3.8 - env: DJANGO=2.0 - - python: 3.8 - env: DJANGO=2.1 - - python: 3.8 - env: DJANGO=2.2 - - python: 3.8 - env: DJANGO=3.0 - - python: 3.8 - env: DJANGO=master - - - python: 3.7 - env: TOXENV=black,flake8 - - - stage: deploy - script: skip - python: 3.7 - after_success: true - deploy: - provider: pypi - user: syrusakbary - on: - tags: true - password: - secure: kymIFCEPUbkgRqe2NAXkWfxMmGRfWvWBOP6LIXdVdkOOkm91fU7bndPGrAjos+/7gN0Org609ZmHSlVXNMJUWcsL2or/x5LcADJ4cZDe+79qynuoRb9xs1Ri4O4SBAuVMZxuVJvs8oUzT2R11ql5vASSMtXgbX+ZDGpmPRVZStkCuXgOc4LBhbPKyl3OFy7UQFPgAEmy3Yjh4ZSKzlXheK+S6mmr60+DCIjpaA0BWPxYK9FUE0qm7JJbHLUbwsUP/QMp5MmGjwFisXCNsIe686B7QKRaiOw62eJc2R7He8AuEC8T9OM4kRwDlecSn8mMpkoSB7QWtlJ+6XdLrJFPNvtrOfgfzS9/96Qrw9WlOslk68hMlhJeRb0s2YUD8tiV3UUkvbL1mfFoS4SI9U+rojS55KhUEJWHg1w7DjoOPoZmaIL2ChRupmvrFYNAGae1cxwG3Urh+t3wYlN3gpKsRDe5GOT7Wm2tr0ad3McCpDGUwSChX59BAJXe/MoLxkKScTrMyR8yMxHOF0b4zpVn5l7xB/o2Ik4zavx5q/0rGBMK2D+5d+gpQogKShoquTPsZUwO7sB5hYeH2hqGqpeGzZtb76E2zZYd18pJ0FsBudm5+KWjYdZ+vbtGrLxdTXJ1EEtzVXm0lscykTpqUucbXSa51dhStJvW2xEEz6p3rHo= - distributions: "sdist bdist_wheel" diff --git a/tox.ini b/tox.ini index feeacfd..c932c4d 100644 --- a/tox.ini +++ b/tox.ini @@ -4,7 +4,14 @@ envlist = py{36,37,38}-django30, black,flake8 -[travis:env] +[gh-actions] +python = + 2.7: py27 + 3.6: py36 + 3.7: py37 + 3.8: py38 + +[gh-actions:env] DJANGO = 1.11: django111 2.0: django20 @@ -30,13 +37,13 @@ deps = commands = {posargs:py.test --cov=graphene_django graphene_django examples} [testenv:black] -basepython = python3.7 +basepython = python3.8 deps = -e.[dev] commands = black --exclude "/migrations/" graphene_django examples setup.py --check [testenv:flake8] -basepython = python3.7 +basepython = python3.8 deps = -e.[dev] commands = - flake8 graphene_django examples + flake8 graphene_django examples setup.py From 894c564ab717f9b71ec6f73cd11fcd37da521596 Mon Sep 17 00:00:00 2001 From: Jack W Date: Sat, 9 May 2020 12:09:17 +0100 Subject: [PATCH 21/79] Convert nullable BooleanField to nullable Boolean. (#777) --- graphene_django/converter.py | 6 +----- graphene_django/tests/test_converter.py | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/graphene_django/converter.py b/graphene_django/converter.py index bd8f79d..c84b61a 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -152,13 +152,9 @@ def convert_field_to_int(field, registry=None): return Int(description=field.help_text, required=not field.null) +@convert_django_field.register(models.NullBooleanField) @convert_django_field.register(models.BooleanField) def convert_field_to_boolean(field, registry=None): - return NonNull(Boolean, description=field.help_text) - - -@convert_django_field.register(models.NullBooleanField) -def convert_field_to_nullboolean(field, registry=None): return Boolean(description=field.help_text, required=not field.null) diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index 6227326..8e4495a 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -25,12 +25,19 @@ from .models import Article, Film, FilmDetails, Reporter def assert_conversion(django_field, graphene_field, *args, **kwargs): - field = django_field(help_text="Custom Help Text", null=True, *args, **kwargs) + _kwargs = kwargs.copy() + if "null" not in kwargs: + _kwargs["null"] = True + field = django_field(help_text="Custom Help Text", *args, **_kwargs) graphene_type = convert_django_field(field) assert isinstance(graphene_type, graphene_field) field = graphene_type.Field() assert field.description == "Custom Help Text" - nonnull_field = django_field(null=False, *args, **kwargs) + + _kwargs = kwargs.copy() + if "null" not in kwargs: + _kwargs["null"] = False + nonnull_field = django_field(*args, **_kwargs) if not nonnull_field.null: nonnull_graphene_type = convert_django_field(nonnull_field) nonnull_field = nonnull_graphene_type.Field() @@ -126,7 +133,12 @@ def test_should_integer_convert_int(): def test_should_boolean_convert_boolean(): - field = assert_conversion(models.BooleanField, graphene.NonNull) + assert_conversion(models.BooleanField, graphene.Boolean, null=True) + + +def test_should_boolean_convert_non_null_boolean(): + field = assert_conversion(models.BooleanField, graphene.Boolean, null=False) + assert isinstance(field.type, graphene.NonNull) assert field.type.of_type == graphene.Boolean From 975f45ed1ab2387ef9b70231441bade209f2370e Mon Sep 17 00:00:00 2001 From: Marc Simon Date: Sat, 9 May 2020 13:15:16 +0200 Subject: [PATCH 22/79] GraphQlView: Do not 'instantiate_middleware' if middleware is already a MiddlewareManager (#952) --- graphene_django/views.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/graphene_django/views.py b/graphene_django/views.py index 4c58839..16bf34b 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -15,6 +15,7 @@ from graphql.error import format_error as format_graphql_error from graphql.error import GraphQLError from graphql.execution import ExecutionResult from graphql.type.schema import GraphQLSchema +from graphql.execution.middleware import MiddlewareManager from .settings import graphene_settings @@ -86,7 +87,10 @@ class GraphQLView(View): self.schema = self.schema or schema if middleware is not None: - self.middleware = list(instantiate_middleware(middleware)) + if isinstance(middleware, MiddlewareManager): + self.middleware = middleware + else: + self.middleware = list(instantiate_middleware(middleware)) self.executor = executor self.root_value = root_value self.pretty = self.pretty or pretty From 8990e173ac5501f62773b8596da93f7c0789acbd Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sat, 9 May 2020 12:19:02 +0100 Subject: [PATCH 23/79] Add extra types documentation (#902) --- docs/extra-types.rst | 12 ++++++++++++ docs/index.rst | 1 + 2 files changed, 13 insertions(+) create mode 100644 docs/extra-types.rst diff --git a/docs/extra-types.rst b/docs/extra-types.rst new file mode 100644 index 0000000..22927d6 --- /dev/null +++ b/docs/extra-types.rst @@ -0,0 +1,12 @@ +Extra Types +=========== + +Here are some libraries that provide common types for Django specific fields. + + +GeoDjango +--------- + +Use the graphene-gis_ library to add GeoDjango types to your Schema. + +.. _graphene-gis: https://github.com/EverWinter23/graphene-gis diff --git a/docs/index.rst b/docs/index.rst index 602f8dd..93f37db 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -25,6 +25,7 @@ For more advanced use, check out the Relay tutorial. tutorial-relay schema queries + extra-types mutations filtering authorization From b4e34a5794edd430f25048c7665e689ab0c085b4 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sat, 9 May 2020 12:28:03 +0100 Subject: [PATCH 24/79] Improve DjangoListField (#929) --- docs/fields.rst | 83 +++++++++++++ docs/filtering.rst | 2 +- docs/index.rst | 1 + docs/queries.rst | 4 + graphene_django/__init__.py | 9 +- graphene_django/fields.py | 26 ++-- graphene_django/tests/test_fields.py | 179 ++++++++++++++++++++++++++- 7 files changed, 290 insertions(+), 14 deletions(-) create mode 100644 docs/fields.rst diff --git a/docs/fields.rst b/docs/fields.rst new file mode 100644 index 0000000..1a8afc3 --- /dev/null +++ b/docs/fields.rst @@ -0,0 +1,83 @@ +Fields +====== + +Graphene-Django provides some useful fields to help integrate Django with your GraphQL +Schema. + +DjangoListField +--------------- + +``DjangoListField`` allows you to define a list of :ref:`DjangoObjectType`'s. By default it will resolve the default queryset of the Django model. + +.. code:: python + + from graphene import ObjectType, Schema + from graphene_django import DjangoListField + + class RecipeType(DjangoObjectType): + class Meta: + model = Recipe + fields = ("title", "instructions") + + class Query(ObjectType): + recipes = DjangoListField(RecipeType) + + schema = Schema(query=Query) + +The above code results in the following schema definition: + +.. code:: + + schema { + query: Query + } + + type Query { + recipes: [RecipeType!] + } + + type RecipeType { + title: String! + instructions: String! + } + +Custom resolvers +**************** + +If your ``DjangoObjectType`` has defined a custom +:ref:`get_queryset` method, when resolving a +``DjangoListField`` it will be called with either the return of the field +resolver (if one is defined) or the default queryeset from the Django model. + +For example the following schema will only resolve recipes which have been +published and have a title: + +.. code:: python + + from graphene import ObjectType, Schema + from graphene_django import DjangoListField + + class RecipeType(DjangoObjectType): + class Meta: + model = Recipe + fields = ("title", "instructions") + + @classmethod + def get_queryset(cls, queryset, info): + # Filter out recipes that have no title + return queryset.exclude(title__exact="") + + class Query(ObjectType): + recipes = DjangoListField(RecipeType) + + def resolve_recipes(parent, info): + # Only get recipes that have been published + return Recipe.objects.filter(published=True) + + schema = Schema(query=Query) + + +DjangoConnectionField +--------------------- + +*TODO* diff --git a/docs/filtering.rst b/docs/filtering.rst index 0d37f46..dbbab9d 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -1,7 +1,7 @@ Filtering ========= -Graphene integrates with +Graphene-Django integrates with `django-filter `__ (2.x for Python 3 or 1.x for Python 2) to provide filtering of results. See the `usage documentation `__ diff --git a/docs/index.rst b/docs/index.rst index 93f37db..f4f718c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -25,6 +25,7 @@ For more advanced use, check out the Relay tutorial. tutorial-relay schema queries + fields extra-types mutations filtering diff --git a/docs/queries.rst b/docs/queries.rst index 36cdab1..4b3f718 100644 --- a/docs/queries.rst +++ b/docs/queries.rst @@ -1,3 +1,5 @@ +.. _queries-objecttypes: + Queries & ObjectTypes ===================== @@ -205,6 +207,8 @@ need to create the most basic class for this to work: class Meta: model = Category +.. _django-objecttype-get-queryset: + Default QuerySet ----------------- diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 38f8d8a..62318e9 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,6 +1,11 @@ +from .fields import DjangoConnectionField, DjangoListField from .types import DjangoObjectType -from .fields import DjangoConnectionField __version__ = "2.9.1" -__all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] +__all__ = [ + "__version__", + "DjangoObjectType", + "DjangoListField", + "DjangoConnectionField", +] diff --git a/graphene_django/fields.py b/graphene_django/fields.py index fb6b98a..7539cf2 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -38,16 +38,21 @@ class DjangoListField(Field): def model(self): return self._underlying_type._meta.model + def get_default_queryset(self): + return self.model._default_manager.get_queryset() + @staticmethod - def list_resolver(django_object_type, resolver, root, info, **args): + def list_resolver( + django_object_type, resolver, default_queryset, root, info, **args + ): queryset = maybe_queryset(resolver(root, info, **args)) if queryset is None: - # Default to Django Model queryset - # N.B. This happens if DjangoListField is used in the top level Query object - model_manager = django_object_type._meta.model.objects - queryset = maybe_queryset( - django_object_type.get_queryset(model_manager, info) - ) + queryset = default_queryset + + if isinstance(queryset, QuerySet): + # Pass queryset to the DjangoObjectType get_queryset method + queryset = maybe_queryset(django_object_type.get_queryset(queryset, info)) + return queryset def get_resolver(self, parent_resolver): @@ -55,7 +60,12 @@ class DjangoListField(Field): if isinstance(_type, NonNull): _type = _type.of_type django_object_type = _type.of_type.of_type - return partial(self.list_resolver, django_object_type, parent_resolver) + return partial( + self.list_resolver, + django_object_type, + parent_resolver, + self.get_default_queryset(), + ) class DjangoConnectionField(ConnectionField): diff --git a/graphene_django/tests/test_fields.py b/graphene_django/tests/test_fields.py index 67b3a35..cd5bd1b 100644 --- a/graphene_django/tests/test_fields.py +++ b/graphene_django/tests/test_fields.py @@ -1,4 +1,5 @@ import datetime +from django.db.models import Count import pytest @@ -141,13 +142,26 @@ class TestDjangoListField: pub_date_time=datetime.datetime.now(), editor=r1, ) + ArticleModel.objects.create( + headline="Not so good news", + reporter=r1, + pub_date=datetime.date.today(), + pub_date_time=datetime.datetime.now(), + editor=r1, + ) result = schema.execute(query) assert not result.errors assert result.data == { "reporters": [ - {"firstName": "Tara", "articles": [{"headline": "Amazing news"}]}, + { + "firstName": "Tara", + "articles": [ + {"headline": "Amazing news"}, + {"headline": "Not so good news"}, + ], + }, {"firstName": "Debra", "articles": []}, ] } @@ -163,8 +177,8 @@ class TestDjangoListField: model = ReporterModel fields = ("first_name", "articles") - def resolve_reporters(reporter, info): - return reporter.articles.all() + def resolve_articles(reporter, info): + return reporter.articles.filter(headline__contains="Amazing") class Query(ObjectType): reporters = DjangoListField(Reporter) @@ -192,6 +206,13 @@ class TestDjangoListField: pub_date_time=datetime.datetime.now(), editor=r1, ) + ArticleModel.objects.create( + headline="Not so good news", + reporter=r1, + pub_date=datetime.date.today(), + pub_date_time=datetime.datetime.now(), + editor=r1, + ) result = schema.execute(query) @@ -202,3 +223,155 @@ class TestDjangoListField: {"firstName": "Debra", "articles": []}, ] } + + def test_get_queryset_filter(self): + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ("first_name", "articles") + + @classmethod + def get_queryset(cls, queryset, info): + # Only get reporters with at least 1 article + return queryset.annotate(article_count=Count("articles")).filter( + article_count__gt=0 + ) + + class Query(ObjectType): + reporters = DjangoListField(Reporter) + + def resolve_reporters(_, info): + return ReporterModel.objects.all() + + schema = Schema(query=Query) + + query = """ + query { + reporters { + firstName + } + } + """ + + r1 = ReporterModel.objects.create(first_name="Tara", last_name="West") + ReporterModel.objects.create(first_name="Debra", last_name="Payne") + + ArticleModel.objects.create( + headline="Amazing news", + reporter=r1, + pub_date=datetime.date.today(), + pub_date_time=datetime.datetime.now(), + editor=r1, + ) + + result = schema.execute(query) + + assert not result.errors + assert result.data == {"reporters": [{"firstName": "Tara"},]} + + def test_resolve_list(self): + """Resolving a plain list should work (and not call get_queryset)""" + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ("first_name", "articles") + + @classmethod + def get_queryset(cls, queryset, info): + # Only get reporters with at least 1 article + return queryset.annotate(article_count=Count("articles")).filter( + article_count__gt=0 + ) + + class Query(ObjectType): + reporters = DjangoListField(Reporter) + + def resolve_reporters(_, info): + return [ReporterModel.objects.get(first_name="Debra")] + + schema = Schema(query=Query) + + query = """ + query { + reporters { + firstName + } + } + """ + + r1 = ReporterModel.objects.create(first_name="Tara", last_name="West") + ReporterModel.objects.create(first_name="Debra", last_name="Payne") + + ArticleModel.objects.create( + headline="Amazing news", + reporter=r1, + pub_date=datetime.date.today(), + pub_date_time=datetime.datetime.now(), + editor=r1, + ) + + result = schema.execute(query) + + assert not result.errors + assert result.data == {"reporters": [{"firstName": "Debra"},]} + + def test_get_queryset_foreign_key(self): + class Article(DjangoObjectType): + class Meta: + model = ArticleModel + fields = ("headline",) + + @classmethod + def get_queryset(cls, queryset, info): + # Rose tinted glasses + return queryset.exclude(headline__contains="Not so good") + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = ("first_name", "articles") + + class Query(ObjectType): + reporters = DjangoListField(Reporter) + + schema = Schema(query=Query) + + query = """ + query { + reporters { + firstName + articles { + headline + } + } + } + """ + + r1 = ReporterModel.objects.create(first_name="Tara", last_name="West") + ReporterModel.objects.create(first_name="Debra", last_name="Payne") + + ArticleModel.objects.create( + headline="Amazing news", + reporter=r1, + pub_date=datetime.date.today(), + pub_date_time=datetime.datetime.now(), + editor=r1, + ) + ArticleModel.objects.create( + headline="Not so good news", + reporter=r1, + pub_date=datetime.date.today(), + pub_date_time=datetime.datetime.now(), + editor=r1, + ) + + result = schema.execute(query) + + assert not result.errors + assert result.data == { + "reporters": [ + {"firstName": "Tara", "articles": [{"headline": "Amazing news"},],}, + {"firstName": "Debra", "articles": []}, + ] + } From 5867331c7bea315f40ba304656639ae5d6816987 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sat, 9 May 2020 12:28:19 +0100 Subject: [PATCH 25/79] Allow defining fields as an empty list (#871) --- graphene_django/tests/test_types.py | 11 +++++++++++ graphene_django/types.py | 22 +++++++++++++--------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 9b3ceb6..4d14749 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -230,6 +230,17 @@ def test_django_objecttype_fields(): assert fields == ["id", "email", "films"] +@with_local_registry +def test_django_objecttype_fields_empty(): + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = () + + fields = list(Reporter._meta.fields.keys()) + assert fields == [] + + @with_local_registry def test_django_objecttype_only_fields_and_fields(): with pytest.raises(Exception): diff --git a/graphene_django/types.py b/graphene_django/types.py index 0c0cb1c..18dccb2 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -35,9 +35,15 @@ def construct_fields( fields = OrderedDict() for name, field in _model_fields: - is_not_in_only = only_fields and name not in only_fields + is_not_in_only = ( + only_fields is not None + and only_fields != ALL_FIELDS + and name not in only_fields + ) # is_already_created = name in options.fields - is_excluded = name in exclude_fields # or is_already_created + is_excluded = ( + exclude_fields is not None and name in exclude_fields + ) # or is_already_created # https://docs.djangoproject.com/en/1.10/ref/models/fields/#django.db.models.ForeignKey.related_query_name is_no_backref = str(name).endswith("+") if is_not_in_only or is_excluded or is_no_backref: @@ -65,6 +71,7 @@ def construct_fields( def validate_fields(type_, model, fields, only_fields, exclude_fields): # Validate the given fields against the model's fields and custom fields all_field_names = set(fields.keys()) + only_fields = only_fields if only_fields is not ALL_FIELDS else () for name in only_fields or (): if name in all_field_names: continue @@ -142,10 +149,10 @@ class DjangoObjectType(ObjectType): model=None, registry=None, skip_registry=False, - only_fields=(), # deprecated in favour of `fields` - fields=(), - exclude_fields=(), # deprecated in favour of `exclude` - exclude=(), + only_fields=None, # deprecated in favour of `fields` + fields=None, + exclude_fields=None, # deprecated in favour of `exclude` + exclude=None, filter_fields=None, filterset_class=None, connection=None, @@ -200,9 +207,6 @@ class DjangoObjectType(ObjectType): "Got %s." % type(fields).__name__ ) - if fields == ALL_FIELDS: - fields = None - # Alias exclude_fields -> exclude if exclude_fields and exclude: raise Exception("Can't set both exclude_fields and exclude") From b0613dd0e4b6323230a007ef004fd1f38d03471d Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sat, 9 May 2020 12:32:51 +0100 Subject: [PATCH 26/79] Update __init__.py --- 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 62318e9..b090aff 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.9.1" +__version__ = "2.10.0" __all__ = [ "__version__", From 709611b5771e23223d8fe0a1cfe485e2e3179303 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sat, 9 May 2020 12:37:47 +0100 Subject: [PATCH 27/79] Install wheel before creating distribution --- .github/workflows/deploy.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5dd418e..1cd1011 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -17,6 +17,7 @@ jobs: python-version: 3.8 - name: Build wheel and source tarball run: | + pip install wheel python setup.py sdist bdist_wheel - name: Publish a Python distribution to PyPI uses: pypa/gh-action-pypi-publish@v1.1.0 From 72375b2d149d4c3a27573d5b4d97f86db51c8d6a Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sat, 9 May 2020 13:28:01 +0100 Subject: [PATCH 28/79] Update issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..126ed39 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,34 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: "\U0001F41Bbug" +assignees: '' + +--- + +**Note: for support questions, please use stackoverflow**. This repository's issues are reserved for feature requests and bug reports. + +* **What is the current behavior?** + + + +* **If the current behavior is a bug, please provide the steps to reproduce and if possible a minimal demo of the problem** via +a github repot, https://repl.it or similar (you can use this template as a starting point: https://repl.it/@jkimbo/Graphene-Django-Example). + + + +* **What is the expected behavior?** + + + +* **What is the motivation / use case for changing the behavior?** + + + +* **Please tell us about your environment:** + + - Version: + - Platform: + +* **Other information** (e.g. detailed explanation, stacktraces, related issues, suggestions how to fix, links for us to have context, eg. stackoverflow, gitter, etc) From 079da60c8f885ad2dcea6b1d5731e831d43abd78 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sat, 9 May 2020 13:28:56 +0100 Subject: [PATCH 29/79] Create config.yml --- .github/ISSUE_TEMPLATE/config.yml | 1 + 1 file changed, 1 insertion(+) create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..3ba13e0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false From 0c900065202869cbc97b8dfc209b6822620f61d2 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sat, 9 May 2020 13:29:37 +0100 Subject: [PATCH 30/79] Update issue templates --- .github/ISSUE_TEMPLATE/feature_request.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..bbcbbe7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: '' +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. From b0901104d8aab43a6e8f24895434f20916da8dc8 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sat, 9 May 2020 13:30:35 +0100 Subject: [PATCH 31/79] Update issue templates --- .github/ISSUE_TEMPLATE/feature_request.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index bbcbbe7..99daca0 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -2,7 +2,7 @@ name: Feature request about: Suggest an idea for this project title: '' -labels: '' +labels: "✨enhancement" assignees: '' --- From ed4937f9df61091dc24e8194e0c22d2966726ef0 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sat, 9 May 2020 13:32:20 +0100 Subject: [PATCH 32/79] Update bug_report.md --- .github/ISSUE_TEMPLATE/bug_report.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 126ed39..2c933d7 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -14,7 +14,7 @@ assignees: '' * **If the current behavior is a bug, please provide the steps to reproduce and if possible a minimal demo of the problem** via -a github repot, https://repl.it or similar (you can use this template as a starting point: https://repl.it/@jkimbo/Graphene-Django-Example). +a github repo, https://repl.it or similar (you can use this template as a starting point: https://repl.it/@jkimbo/Graphene-Django-Example). @@ -31,4 +31,4 @@ a github repot, https://repl.it or similar (you can use this template as a start - Version: - Platform: -* **Other information** (e.g. detailed explanation, stacktraces, related issues, suggestions how to fix, links for us to have context, eg. stackoverflow, gitter, etc) +* **Other information** (e.g. detailed explanation, stacktraces, related issues, suggestions how to fix, links for us to have context, eg. stackoverflow) From 2225ed62e1fd05f11fc0f0939e864351e1e53aa7 Mon Sep 17 00:00:00 2001 From: Paul Craciunoiu Date: Sat, 9 May 2020 10:35:09 -0600 Subject: [PATCH 33/79] Do not access the internals of `SimpleLazyObject` (#945) --- graphene_django/tests/test_query.py | 25 +++++++++++++++++++++++++ graphene_django/types.py | 5 +---- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 698ca23..bb9cc88 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -60,6 +60,31 @@ def test_should_query_simplelazy_objects(): assert result.data == {"reporter": {"id": "1"}} +def test_should_query_wrapped_simplelazy_objects(): + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + fields = ("id",) + + class Query(graphene.ObjectType): + reporter = graphene.Field(ReporterType) + + def resolve_reporter(self, info): + return SimpleLazyObject(lambda: SimpleLazyObject(lambda: Reporter(id=1))) + + schema = graphene.Schema(query=Query) + query = """ + query { + reporter { + id + } + } + """ + result = schema.execute(query) + assert not result.errors + assert result.data == {"reporter": {"id": "1"}} + + def test_should_query_well(): class ReporterType(DjangoObjectType): class Meta: diff --git a/graphene_django/types.py b/graphene_django/types.py index 18dccb2..b31fd0f 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -272,12 +272,9 @@ class DjangoObjectType(ObjectType): @classmethod def is_type_of(cls, root, info): - if isinstance(root, SimpleLazyObject): - root._setup() - root = root._wrapped if isinstance(root, cls): return True - if not is_valid_django_model(type(root)): + if not is_valid_django_model(root.__class__): raise Exception(('Received incompatible instance "{}".').format(root)) if cls._meta.model._meta.proxy: From a987035ef36d1612df9ad51a2a02de12272b4d26 Mon Sep 17 00:00:00 2001 From: vineethvanga18 <44473939+vineethvanga18@users.noreply.github.com> Date: Tue, 12 May 2020 01:33:17 +0530 Subject: [PATCH 34/79] fix typo (#959) --- docs/fields.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/fields.rst b/docs/fields.rst index 1a8afc3..32ca26c 100644 --- a/docs/fields.rst +++ b/docs/fields.rst @@ -47,7 +47,7 @@ Custom resolvers If your ``DjangoObjectType`` has defined a custom :ref:`get_queryset` method, when resolving a ``DjangoListField`` it will be called with either the return of the field -resolver (if one is defined) or the default queryeset from the Django model. +resolver (if one is defined) or the default queryset from the Django model. For example the following schema will only resolve recipes which have been published and have a title: From d804fe48f23dc51c88a45cdac38d3a672858acc3 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Mon, 18 May 2020 20:20:01 +0100 Subject: [PATCH 35/79] v2.10.1 --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index b090aff..dcd0ba7 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.10.0" +__version__ = "2.10.1" __all__ = [ "__version__", From ee120c48e16dda818cb253fdc36b7053402956b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Thu, 21 May 2020 15:18:43 +0300 Subject: [PATCH 36/79] Use psycopg2-binary in tox (#964) --- tox.ini | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index c932c4d..6744c5b 100644 --- a/tox.ini +++ b/tox.ini @@ -27,7 +27,7 @@ setenv = DJANGO_SETTINGS_MODULE=django_test_settings deps = -e.[test] - psycopg2 + psycopg2-binary django111: Django>=1.11,<2.0 django20: Django>=2.0,<2.1 django21: Django>=2.1,<2.2 From d07642afe685ac7d7eede94f67c1cc951fa5c903 Mon Sep 17 00:00:00 2001 From: Padraic Harley Date: Thu, 21 May 2020 16:16:14 +0100 Subject: [PATCH 37/79] Error in signature of callproc() and execute() (#966) --- graphene_django/debug/sql/tracking.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/graphene_django/debug/sql/tracking.py b/graphene_django/debug/sql/tracking.py index a7c9d8d..dc0f2df 100644 --- a/graphene_django/debug/sql/tracking.py +++ b/graphene_django/debug/sql/tracking.py @@ -148,10 +148,10 @@ class NormalCursorWrapper(object): # We keep `sql` to maintain backwards compatibility self.logger.object.sql.append(_sql) - def callproc(self, procname, params=()): + def callproc(self, procname, params=None): return self._record(self.cursor.callproc, procname, params) - def execute(self, sql, params=()): + def execute(self, sql, params=None): return self._record(self.cursor.execute, sql, params) def executemany(self, sql, param_list): From b1f6d412092eb9e3f76243b513c6f985cd5f7a26 Mon Sep 17 00:00:00 2001 From: Chris Hart Date: Fri, 22 May 2020 06:12:27 -0400 Subject: [PATCH 38/79] fixes minor typo in docs index (#969) --- docs/index.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index f4f718c..be6065c 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,8 +9,8 @@ Graphene-Django provides some additional abstractions that make it easy to add G First time? We recommend you start with the installation guide to get set up and the basic tutorial. It is worth reading the `core graphene docs `__ to familiarize yourself with the basic utilities. -Core tenants ------------- +Core tenets +----------- If you want to expose your data through GraphQL - read the ``Installation``, ``Schema`` and ``Queries`` section. From 5ff40d2d148f1c8be3463e3920ff84c983e585b0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 6 Jun 2020 12:48:01 +0100 Subject: [PATCH 39/79] Bump django from 3.0.3 to 3.0.7 in /examples/cookbook-plain (#978) Bumps [django](https://github.com/django/django) from 3.0.3 to 3.0.7. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/3.0.3...3.0.7) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/cookbook-plain/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cookbook-plain/requirements.txt b/examples/cookbook-plain/requirements.txt index 480f757..ae9ecc9 100644 --- a/examples/cookbook-plain/requirements.txt +++ b/examples/cookbook-plain/requirements.txt @@ -1,4 +1,4 @@ graphene>=2.1,<3 graphene-django>=2.1,<3 graphql-core>=2.1,<3 -django==3.0.3 +django==3.0.7 From 40e9c66db38c59d849f1c8556624b96e5fb5e298 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 6 Jun 2020 12:48:19 +0100 Subject: [PATCH 40/79] Bump django from 3.0.3 to 3.0.7 in /examples/cookbook (#979) Bumps [django](https://github.com/django/django) from 3.0.3 to 3.0.7. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/3.0.3...3.0.7) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- examples/cookbook/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/cookbook/requirements.txt b/examples/cookbook/requirements.txt index 4375fcc..7ae2d89 100644 --- a/examples/cookbook/requirements.txt +++ b/examples/cookbook/requirements.txt @@ -1,5 +1,5 @@ graphene>=2.1,<3 graphene-django>=2.1,<3 graphql-core>=2.1,<3 -django==3.0.3 +django==3.0.7 django-filter>=2 From c00203499b00d40a696a092ab3c64759eddad0f8 Mon Sep 17 00:00:00 2001 From: Paul Craciunoiu Date: Sat, 6 Jun 2020 12:00:21 -0600 Subject: [PATCH 41/79] DjangoConnectionField slice: use max_limit first, if set (#965) --- graphene_django/debug/tests/test_query.py | 81 ++++++++++++++++------- graphene_django/fields.py | 13 ++-- graphene_django/tests/test_query.py | 44 +++++++++++- 3 files changed, 109 insertions(+), 29 deletions(-) diff --git a/graphene_django/debug/tests/test_query.py b/graphene_django/debug/tests/test_query.py index 7226f9b..4c057ed 100644 --- a/graphene_django/debug/tests/test_query.py +++ b/graphene_django/debug/tests/test_query.py @@ -1,4 +1,5 @@ import graphene +import pytest from graphene.relay import Node from graphene_django import DjangoConnectionField, DjangoObjectType @@ -24,7 +25,7 @@ def test_should_query_field(): class Query(graphene.ObjectType): reporter = graphene.Field(ReporterType) - debug = graphene.Field(DjangoDebug, name="_debug") + debug = graphene.Field(DjangoDebug, name="__debug") def resolve_reporter(self, info, **args): return Reporter.objects.first() @@ -34,7 +35,7 @@ def test_should_query_field(): reporter { lastName } - _debug { + __debug { sql { rawSql } @@ -43,7 +44,9 @@ def test_should_query_field(): """ expected = { "reporter": {"lastName": "ABA"}, - "_debug": {"sql": [{"rawSql": str(Reporter.objects.order_by("pk")[:1].query)}]}, + "__debug": { + "sql": [{"rawSql": str(Reporter.objects.order_by("pk")[:1].query)}] + }, } schema = graphene.Schema(query=Query) result = schema.execute( @@ -53,7 +56,10 @@ def test_should_query_field(): assert result.data == expected -def test_should_query_nested_field(): +@pytest.mark.parametrize("max_limit,does_count", [(None, True), (100, False)]) +def test_should_query_nested_field(graphene_settings, max_limit, does_count): + graphene_settings.RELAY_CONNECTION_MAX_LIMIT = max_limit + r1 = Reporter(last_name="ABA") r1.save() r2 = Reporter(last_name="Griffin") @@ -111,11 +117,18 @@ def test_should_query_nested_field(): assert not result.errors query = str(Reporter.objects.order_by("pk")[:1].query) assert result.data["__debug"]["sql"][0]["rawSql"] == query - assert "COUNT" in result.data["__debug"]["sql"][1]["rawSql"] - assert "tests_reporter_pets" in result.data["__debug"]["sql"][2]["rawSql"] - assert "COUNT" in result.data["__debug"]["sql"][3]["rawSql"] - assert "tests_reporter_pets" in result.data["__debug"]["sql"][4]["rawSql"] - assert len(result.data["__debug"]["sql"]) == 5 + if does_count: + assert "COUNT" in result.data["__debug"]["sql"][1]["rawSql"] + assert "tests_reporter_pets" in result.data["__debug"]["sql"][2]["rawSql"] + assert "COUNT" in result.data["__debug"]["sql"][3]["rawSql"] + assert "tests_reporter_pets" in result.data["__debug"]["sql"][4]["rawSql"] + assert len(result.data["__debug"]["sql"]) == 5 + else: + assert len(result.data["__debug"]["sql"]) == 3 + for i in range(len(result.data["__debug"]["sql"])): + assert "COUNT" not in result.data["__debug"]["sql"][i]["rawSql"] + assert "tests_reporter_pets" in result.data["__debug"]["sql"][1]["rawSql"] + assert "tests_reporter_pets" in result.data["__debug"]["sql"][2]["rawSql"] assert result.data["reporter"] == expected["reporter"] @@ -133,7 +146,7 @@ def test_should_query_list(): class Query(graphene.ObjectType): all_reporters = graphene.List(ReporterType) - debug = graphene.Field(DjangoDebug, name="_debug") + debug = graphene.Field(DjangoDebug, name="__debug") def resolve_all_reporters(self, info, **args): return Reporter.objects.all() @@ -143,7 +156,7 @@ def test_should_query_list(): allReporters { lastName } - _debug { + __debug { sql { rawSql } @@ -152,7 +165,7 @@ def test_should_query_list(): """ expected = { "allReporters": [{"lastName": "ABA"}, {"lastName": "Griffin"}], - "_debug": {"sql": [{"rawSql": str(Reporter.objects.all().query)}]}, + "__debug": {"sql": [{"rawSql": str(Reporter.objects.all().query)}]}, } schema = graphene.Schema(query=Query) result = schema.execute( @@ -162,7 +175,10 @@ def test_should_query_list(): assert result.data == expected -def test_should_query_connection(): +@pytest.mark.parametrize("max_limit,does_count", [(None, True), (100, False)]) +def test_should_query_connection(graphene_settings, max_limit, does_count): + graphene_settings.RELAY_CONNECTION_MAX_LIMIT = max_limit + r1 = Reporter(last_name="ABA") r1.save() r2 = Reporter(last_name="Griffin") @@ -175,7 +191,7 @@ def test_should_query_connection(): class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) - debug = graphene.Field(DjangoDebug, name="_debug") + debug = graphene.Field(DjangoDebug, name="__debug") def resolve_all_reporters(self, info, **args): return Reporter.objects.all() @@ -189,7 +205,7 @@ def test_should_query_connection(): } } } - _debug { + __debug { sql { rawSql } @@ -203,12 +219,22 @@ def test_should_query_connection(): ) assert not result.errors assert result.data["allReporters"] == expected["allReporters"] - assert "COUNT" in result.data["_debug"]["sql"][0]["rawSql"] - query = str(Reporter.objects.all()[:1].query) - assert result.data["_debug"]["sql"][1]["rawSql"] == query + if does_count: + assert len(result.data["__debug"]["sql"]) == 2 + assert "COUNT" in result.data["__debug"]["sql"][0]["rawSql"] + query = str(Reporter.objects.all()[:1].query) + assert result.data["__debug"]["sql"][1]["rawSql"] == query + else: + assert len(result.data["__debug"]["sql"]) == 1 + assert "COUNT" not in result.data["__debug"]["sql"][0]["rawSql"] + query = str(Reporter.objects.all()[:1].query) + assert result.data["__debug"]["sql"][0]["rawSql"] == query -def test_should_query_connectionfilter(): +@pytest.mark.parametrize("max_limit,does_count", [(None, True), (100, False)]) +def test_should_query_connectionfilter(graphene_settings, max_limit, does_count): + graphene_settings.RELAY_CONNECTION_MAX_LIMIT = max_limit + from ...filter import DjangoFilterConnectionField r1 = Reporter(last_name="ABA") @@ -224,7 +250,7 @@ def test_should_query_connectionfilter(): class Query(graphene.ObjectType): all_reporters = DjangoFilterConnectionField(ReporterType, fields=["last_name"]) s = graphene.String(resolver=lambda *_: "S") - debug = graphene.Field(DjangoDebug, name="_debug") + debug = graphene.Field(DjangoDebug, name="__debug") def resolve_all_reporters(self, info, **args): return Reporter.objects.all() @@ -238,7 +264,7 @@ def test_should_query_connectionfilter(): } } } - _debug { + __debug { sql { rawSql } @@ -252,6 +278,13 @@ def test_should_query_connectionfilter(): ) assert not result.errors assert result.data["allReporters"] == expected["allReporters"] - assert "COUNT" in result.data["_debug"]["sql"][0]["rawSql"] - query = str(Reporter.objects.all()[:1].query) - assert result.data["_debug"]["sql"][1]["rawSql"] == query + if does_count: + assert len(result.data["__debug"]["sql"]) == 2 + assert "COUNT" in result.data["__debug"]["sql"][0]["rawSql"] + query = str(Reporter.objects.all()[:1].query) + assert result.data["__debug"]["sql"][1]["rawSql"] == query + else: + assert len(result.data["__debug"]["sql"]) == 1 + assert "COUNT" not in result.data["__debug"]["sql"][0]["rawSql"] + query = str(Reporter.objects.all()[:1].query) + assert result.data["__debug"]["sql"][0]["rawSql"] == query diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 7539cf2..9b102bd 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -127,12 +127,15 @@ class DjangoConnectionField(ConnectionField): return connection._meta.node.get_queryset(queryset, info) @classmethod - def resolve_connection(cls, connection, args, iterable): + def resolve_connection(cls, connection, args, iterable, max_limit=None): iterable = maybe_queryset(iterable) + # When slicing from the end, need to retrieve the iterable length. + if args.get("last"): + max_limit = None if isinstance(iterable, QuerySet): - _len = iterable.count() + _len = max_limit or iterable.count() else: - _len = len(iterable) + _len = max_limit or len(iterable) connection = connection_from_list_slice( iterable, args, @@ -189,7 +192,9 @@ class DjangoConnectionField(ConnectionField): # thus the iterable gets refiltered by resolve_queryset # but iterable might be promise iterable = queryset_resolver(connection, iterable, info, args) - on_resolve = partial(cls.resolve_connection, connection, args) + on_resolve = partial( + cls.resolve_connection, connection, args, max_limit=max_limit + ) if Promise.is_thenable(iterable): return Promise.resolve(iterable).then(on_resolve) diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index bb9cc88..e6ed49e 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1084,6 +1084,48 @@ def test_should_resolve_get_queryset_connectionfields(): assert result.data == expected +REPORTERS = [ + dict( + first_name="First {}".format(i), + last_name="Last {}".format(i), + email="johndoe+{}@example.com".format(i), + a_choice=1, + ) + for i in range(6) +] + + +def test_should_return_max_limit(graphene_settings): + graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 4 + reporters = [Reporter(**kwargs) for kwargs in REPORTERS] + Reporter.objects.bulk_create(reporters) + + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + + class Query(graphene.ObjectType): + all_reporters = DjangoConnectionField(ReporterType) + + schema = graphene.Schema(query=Query) + query = """ + query AllReporters { + allReporters { + edges { + node { + id + } + } + } + } + """ + + result = schema.execute(query) + assert not result.errors + assert len(result.data["allReporters"]["edges"]) == 4 + + def test_should_preserve_prefetch_related(django_assert_num_queries): class ReporterType(DjangoObjectType): class Meta: @@ -1130,7 +1172,7 @@ def test_should_preserve_prefetch_related(django_assert_num_queries): } """ schema = graphene.Schema(query=Query) - with django_assert_num_queries(3) as captured: + with django_assert_num_queries(2) as captured: result = schema.execute(query) assert not result.errors From 56f1db80cf7a731d31cd5318f9cbb040f4e4fffd Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Wed, 10 Jun 2020 17:41:11 +0100 Subject: [PATCH 42/79] Update setup.py classifiers (#987) Fixes https://github.com/graphql-python/graphene-django/issues/985 --- setup.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/setup.py b/setup.py index 560549a..affaec0 100644 --- a/setup.py +++ b/setup.py @@ -48,10 +48,14 @@ setup( "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", "Programming Language :: Python :: Implementation :: PyPy", + "Framework :: Django", + "Framework :: Django :: 1.11", + "Framework :: Django :: 2.2", + "Framework :: Django :: 3.0", ], keywords="api graphql protocol rest relay graphene", packages=find_packages(exclude=["tests"]), From 48bfc395ee0716caa066c1fc2b764be83090c821 Mon Sep 17 00:00:00 2001 From: "Yuyang Zhang(helloqiu)" Date: Wed, 10 Jun 2020 17:52:45 +0100 Subject: [PATCH 43/79] fix(converter): wrap field with NonNull if it is required (#545) Co-authored-by: Jonathan Kim --- graphene_django/converter.py | 18 +++++++++++++----- graphene_django/tests/test_converter.py | 22 +++++++++++++++++++++- 2 files changed, 34 insertions(+), 6 deletions(-) diff --git a/graphene_django/converter.py b/graphene_django/converter.py index c84b61a..92963d6 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -255,10 +255,14 @@ def convert_field_to_djangomodel(field, registry=None): @convert_django_field.register(ArrayField) def convert_postgres_array_to_list(field, registry=None): - base_type = convert_django_field(field.base_field) - if not isinstance(base_type, (List, NonNull)): - base_type = type(base_type) - return List(base_type, description=field.help_text, required=not field.null) + inner_type = convert_django_field(field.base_field) + if not isinstance(inner_type, (List, NonNull)): + inner_type = ( + NonNull(type(inner_type)) + if inner_type.kwargs["required"] + else type(inner_type) + ) + return List(inner_type, description=field.help_text, required=not field.null) @convert_django_field.register(HStoreField) @@ -271,5 +275,9 @@ def convert_postgres_field_to_string(field, registry=None): def convert_postgres_range_to_string(field, registry=None): inner_type = convert_django_field(field.base_field) if not isinstance(inner_type, (List, NonNull)): - inner_type = type(inner_type) + inner_type = ( + NonNull(type(inner_type)) + if inner_type.kwargs["required"] + else type(inner_type) + ) return List(inner_type, description=field.help_text, required=not field.null) diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index 8e4495a..f6e3606 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -310,6 +310,14 @@ def test_should_postgres_array_convert_list(): ) assert isinstance(field.type, graphene.NonNull) assert isinstance(field.type.of_type, graphene.List) + assert isinstance(field.type.of_type.of_type, graphene.NonNull) + assert field.type.of_type.of_type.of_type == graphene.String + + field = assert_conversion( + ArrayField, graphene.List, models.CharField(max_length=100, null=True) + ) + assert isinstance(field.type, graphene.NonNull) + assert isinstance(field.type.of_type, graphene.List) assert field.type.of_type.of_type == graphene.String @@ -321,6 +329,17 @@ def test_should_postgres_array_multiple_convert_list(): assert isinstance(field.type, graphene.NonNull) assert isinstance(field.type.of_type, graphene.List) assert isinstance(field.type.of_type.of_type, graphene.List) + assert isinstance(field.type.of_type.of_type.of_type, graphene.NonNull) + assert field.type.of_type.of_type.of_type.of_type == graphene.String + + field = assert_conversion( + ArrayField, + graphene.List, + ArrayField(models.CharField(max_length=100, null=True)), + ) + assert isinstance(field.type, graphene.NonNull) + assert isinstance(field.type.of_type, graphene.List) + assert isinstance(field.type.of_type.of_type, graphene.List) assert field.type.of_type.of_type.of_type == graphene.String @@ -341,7 +360,8 @@ def test_should_postgres_range_convert_list(): field = assert_conversion(IntegerRangeField, graphene.List) assert isinstance(field.type, graphene.NonNull) assert isinstance(field.type.of_type, graphene.List) - assert field.type.of_type.of_type == graphene.Int + assert isinstance(field.type.of_type.of_type, graphene.NonNull) + assert field.type.of_type.of_type.of_type == graphene.Int def test_generate_enum_name(graphene_settings): From 3c6733e1215680485a372e3b34ee8af9548bfc72 Mon Sep 17 00:00:00 2001 From: Hubert Siuzdak <35269911+hubertsiuzdak@users.noreply.github.com> Date: Thu, 25 Jun 2020 13:56:06 +0200 Subject: [PATCH 44/79] Fix filtering with GlobalIDFilter (#977) --- graphene_django/filter/fields.py | 8 +- graphene_django/filter/tests/test_fields.py | 108 ++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py index a46a4b7..3a98e8d 100644 --- a/graphene_django/filter/fields.py +++ b/graphene_django/filter/fields.py @@ -1,6 +1,7 @@ from collections import OrderedDict from functools import partial +from django.core.exceptions import ValidationError from graphene.types.argument import to_arguments from ..fields import DjangoConnectionField from .utils import get_filtering_args_from_filterset, get_filterset_class @@ -59,7 +60,12 @@ class DjangoFilterConnectionField(DjangoConnectionField): connection, iterable, info, args ) filter_kwargs = {k: v for k, v in args.items() if k in filtering_args} - return filterset_class(data=filter_kwargs, queryset=qs, request=info.context).qs + filterset = filterset_class( + data=filter_kwargs, queryset=qs, request=info.context + ) + if filterset.form.is_valid(): + return filterset.qs + raise ValidationError(filterset.form.errors.as_json()) def get_queryset_resolver(self): return partial( diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index 166d806..b8ae6fe 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -400,6 +400,114 @@ def test_global_id_field_relation(): assert id_filter.field_class == GlobalIDFormField +def test_global_id_field_relation_with_filter(): + class ReporterFilterNode(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + filter_fields = ["first_name", "articles"] + + class ArticleFilterNode(DjangoObjectType): + class Meta: + model = Article + interfaces = (Node,) + filter_fields = ["headline", "reporter"] + + class Query(ObjectType): + all_reporters = DjangoFilterConnectionField(ReporterFilterNode) + all_articles = DjangoFilterConnectionField(ArticleFilterNode) + reporter = Field(ReporterFilterNode) + article = Field(ArticleFilterNode) + + r1 = Reporter.objects.create(first_name="r1", last_name="r1", email="r1@test.com") + r2 = Reporter.objects.create(first_name="r2", last_name="r2", email="r2@test.com") + Article.objects.create( + headline="a1", + pub_date=datetime.now(), + pub_date_time=datetime.now(), + reporter=r1, + editor=r1, + ) + Article.objects.create( + headline="a2", + pub_date=datetime.now(), + pub_date_time=datetime.now(), + reporter=r2, + editor=r2, + ) + + # Query articles created by the reporter `r1` + query = """ + query { + allArticles (reporter: "UmVwb3J0ZXJGaWx0ZXJOb2RlOjE=") { + edges { + node { + id + } + } + } + } + """ + schema = Schema(query=Query) + result = schema.execute(query) + assert not result.errors + # We should only get back a single article + assert len(result.data["allArticles"]["edges"]) == 1 + + +def test_global_id_field_relation_with_filter_not_valid_id(): + class ReporterFilterNode(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + filter_fields = ["first_name", "articles"] + + class ArticleFilterNode(DjangoObjectType): + class Meta: + model = Article + interfaces = (Node,) + filter_fields = ["headline", "reporter"] + + class Query(ObjectType): + all_reporters = DjangoFilterConnectionField(ReporterFilterNode) + all_articles = DjangoFilterConnectionField(ArticleFilterNode) + reporter = Field(ReporterFilterNode) + article = Field(ArticleFilterNode) + + r1 = Reporter.objects.create(first_name="r1", last_name="r1", email="r1@test.com") + r2 = Reporter.objects.create(first_name="r2", last_name="r2", email="r2@test.com") + Article.objects.create( + headline="a1", + pub_date=datetime.now(), + pub_date_time=datetime.now(), + reporter=r1, + editor=r1, + ) + Article.objects.create( + headline="a2", + pub_date=datetime.now(), + pub_date_time=datetime.now(), + reporter=r2, + editor=r2, + ) + + # Filter by the global ID that does not exist + query = """ + query { + allArticles (reporter: "fake_global_id") { + edges { + node { + id + } + } + } + } + """ + schema = Schema(query=Query) + result = schema.execute(query) + assert "Invalid ID specified." in result.errors[0].message + + def test_global_id_multiple_field_implicit(): field = DjangoFilterConnectionField(ReporterNode, fields=["pets"]) filterset_class = field.filterset_class From 3c229b619efb546971c3df46e30a9ff18aca5721 Mon Sep 17 00:00:00 2001 From: Paul Craciunoiu Date: Thu, 25 Jun 2020 06:00:24 -0600 Subject: [PATCH 45/79] Fix hasNextPage - revert to count. Fix after (#986) Co-authored-by: Jonathan Kim --- graphene_django/debug/tests/test_query.py | 57 ++++++++--------------- graphene_django/fields.py | 32 ++++++++----- graphene_django/tests/test_query.py | 55 +++++++++++++++++++++- 3 files changed, 94 insertions(+), 50 deletions(-) diff --git a/graphene_django/debug/tests/test_query.py b/graphene_django/debug/tests/test_query.py index 4c057ed..d71c3fb 100644 --- a/graphene_django/debug/tests/test_query.py +++ b/graphene_django/debug/tests/test_query.py @@ -56,8 +56,8 @@ def test_should_query_field(): assert result.data == expected -@pytest.mark.parametrize("max_limit,does_count", [(None, True), (100, False)]) -def test_should_query_nested_field(graphene_settings, max_limit, does_count): +@pytest.mark.parametrize("max_limit", [None, 100]) +def test_should_query_nested_field(graphene_settings, max_limit): graphene_settings.RELAY_CONNECTION_MAX_LIMIT = max_limit r1 = Reporter(last_name="ABA") @@ -117,18 +117,11 @@ def test_should_query_nested_field(graphene_settings, max_limit, does_count): assert not result.errors query = str(Reporter.objects.order_by("pk")[:1].query) assert result.data["__debug"]["sql"][0]["rawSql"] == query - if does_count: - assert "COUNT" in result.data["__debug"]["sql"][1]["rawSql"] - assert "tests_reporter_pets" in result.data["__debug"]["sql"][2]["rawSql"] - assert "COUNT" in result.data["__debug"]["sql"][3]["rawSql"] - assert "tests_reporter_pets" in result.data["__debug"]["sql"][4]["rawSql"] - assert len(result.data["__debug"]["sql"]) == 5 - else: - assert len(result.data["__debug"]["sql"]) == 3 - for i in range(len(result.data["__debug"]["sql"])): - assert "COUNT" not in result.data["__debug"]["sql"][i]["rawSql"] - assert "tests_reporter_pets" in result.data["__debug"]["sql"][1]["rawSql"] - assert "tests_reporter_pets" in result.data["__debug"]["sql"][2]["rawSql"] + assert "COUNT" in result.data["__debug"]["sql"][1]["rawSql"] + assert "tests_reporter_pets" in result.data["__debug"]["sql"][2]["rawSql"] + assert "COUNT" in result.data["__debug"]["sql"][3]["rawSql"] + assert "tests_reporter_pets" in result.data["__debug"]["sql"][4]["rawSql"] + assert len(result.data["__debug"]["sql"]) == 5 assert result.data["reporter"] == expected["reporter"] @@ -175,8 +168,8 @@ def test_should_query_list(): assert result.data == expected -@pytest.mark.parametrize("max_limit,does_count", [(None, True), (100, False)]) -def test_should_query_connection(graphene_settings, max_limit, does_count): +@pytest.mark.parametrize("max_limit", [None, 100]) +def test_should_query_connection(graphene_settings, max_limit): graphene_settings.RELAY_CONNECTION_MAX_LIMIT = max_limit r1 = Reporter(last_name="ABA") @@ -219,20 +212,14 @@ def test_should_query_connection(graphene_settings, max_limit, does_count): ) assert not result.errors assert result.data["allReporters"] == expected["allReporters"] - if does_count: - assert len(result.data["__debug"]["sql"]) == 2 - assert "COUNT" in result.data["__debug"]["sql"][0]["rawSql"] - query = str(Reporter.objects.all()[:1].query) - assert result.data["__debug"]["sql"][1]["rawSql"] == query - else: - assert len(result.data["__debug"]["sql"]) == 1 - assert "COUNT" not in result.data["__debug"]["sql"][0]["rawSql"] - query = str(Reporter.objects.all()[:1].query) - assert result.data["__debug"]["sql"][0]["rawSql"] == query + assert len(result.data["__debug"]["sql"]) == 2 + assert "COUNT" in result.data["__debug"]["sql"][0]["rawSql"] + query = str(Reporter.objects.all()[:1].query) + assert result.data["__debug"]["sql"][1]["rawSql"] == query -@pytest.mark.parametrize("max_limit,does_count", [(None, True), (100, False)]) -def test_should_query_connectionfilter(graphene_settings, max_limit, does_count): +@pytest.mark.parametrize("max_limit", [None, 100]) +def test_should_query_connectionfilter(graphene_settings, max_limit): graphene_settings.RELAY_CONNECTION_MAX_LIMIT = max_limit from ...filter import DjangoFilterConnectionField @@ -278,13 +265,7 @@ def test_should_query_connectionfilter(graphene_settings, max_limit, does_count) ) assert not result.errors assert result.data["allReporters"] == expected["allReporters"] - if does_count: - assert len(result.data["__debug"]["sql"]) == 2 - assert "COUNT" in result.data["__debug"]["sql"][0]["rawSql"] - query = str(Reporter.objects.all()[:1].query) - assert result.data["__debug"]["sql"][1]["rawSql"] == query - else: - assert len(result.data["__debug"]["sql"]) == 1 - assert "COUNT" not in result.data["__debug"]["sql"][0]["rawSql"] - query = str(Reporter.objects.all()[:1].query) - assert result.data["__debug"]["sql"][0]["rawSql"] == query + assert len(result.data["__debug"]["sql"]) == 2 + assert "COUNT" in result.data["__debug"]["sql"][0]["rawSql"] + query = str(Reporter.objects.all()[:1].query) + assert result.data["__debug"]["sql"][1]["rawSql"] == query diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 9b102bd..ac7ce45 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -2,7 +2,10 @@ from functools import partial import six from django.db.models.query import QuerySet -from graphql_relay.connection.arrayconnection import connection_from_list_slice +from graphql_relay.connection.arrayconnection import ( + connection_from_list_slice, + get_offset_with_default, +) from promise import Promise from graphene import NonNull @@ -129,25 +132,32 @@ class DjangoConnectionField(ConnectionField): @classmethod def resolve_connection(cls, connection, args, iterable, max_limit=None): iterable = maybe_queryset(iterable) - # When slicing from the end, need to retrieve the iterable length. - if args.get("last"): - max_limit = None + if isinstance(iterable, QuerySet): - _len = max_limit or iterable.count() + list_length = iterable.count() + list_slice_length = ( + min(max_limit, list_length) if max_limit is not None else list_length + ) else: - _len = max_limit or len(iterable) + list_length = len(iterable) + list_slice_length = ( + min(max_limit, list_length) if max_limit is not None else list_length + ) + + after = get_offset_with_default(args.get("after"), -1) + 1 + connection = connection_from_list_slice( - iterable, + iterable[after:], args, - slice_start=0, - list_length=_len, - list_slice_length=_len, + slice_start=after, + list_length=list_length, + list_slice_length=list_slice_length, connection_type=connection, edge_type=connection.Edge, pageinfo_type=PageInfo, ) connection.iterable = iterable - connection.length = _len + connection.length = list_length return connection @classmethod diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index e6ed49e..64f54bb 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1126,6 +1126,59 @@ def test_should_return_max_limit(graphene_settings): assert len(result.data["allReporters"]["edges"]) == 4 +def test_should_have_next_page(graphene_settings): + graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 6 + reporters = [Reporter(**kwargs) for kwargs in REPORTERS] + Reporter.objects.bulk_create(reporters) + db_reporters = Reporter.objects.all() + + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + + class Query(graphene.ObjectType): + all_reporters = DjangoConnectionField(ReporterType) + + schema = graphene.Schema(query=Query) + # Need first: 4 here to trigger the `has_next_page` logic in graphql-relay + # See `arrayconnection.py::connection_from_list_slice`: + # has_next_page=isinstance(first, int) and end_offset < upper_bound + query = """ + query AllReporters($first: Int, $after: String) { + allReporters(first: $first, after: $after) { + pageInfo { + hasNextPage + endCursor + } + edges { + node { + id + } + } + } + } + """ + + result = schema.execute(query, variable_values=dict(first=4)) + assert not result.errors + assert len(result.data["allReporters"]["edges"]) == 4 + assert result.data["allReporters"]["pageInfo"]["hasNextPage"] + + last_result = result.data["allReporters"]["pageInfo"]["endCursor"] + result2 = schema.execute(query, variable_values=dict(first=4, after=last_result)) + assert not result2.errors + assert len(result2.data["allReporters"]["edges"]) == 2 + assert not result2.data["allReporters"]["pageInfo"]["hasNextPage"] + gql_reporters = ( + result.data["allReporters"]["edges"] + result2.data["allReporters"]["edges"] + ) + + assert {to_global_id("ReporterType", reporter.id) for reporter in db_reporters} == { + gql_reporter["node"]["id"] for gql_reporter in gql_reporters + } + + def test_should_preserve_prefetch_related(django_assert_num_queries): class ReporterType(DjangoObjectType): class Meta: @@ -1172,7 +1225,7 @@ def test_should_preserve_prefetch_related(django_assert_num_queries): } """ schema = graphene.Schema(query=Query) - with django_assert_num_queries(2) as captured: + with django_assert_num_queries(3) as captured: result = schema.execute(query) assert not result.errors From 3026181b28acd6dddc8cc537636eb8283498d7f1 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Thu, 25 Jun 2020 15:10:56 +0100 Subject: [PATCH 46/79] Set first amount to max limit if not set (#993) --- graphene_django/fields.py | 3 +++ graphene_django/tests/test_query.py | 7 ++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/graphene_django/fields.py b/graphene_django/fields.py index ac7ce45..641f423 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -146,6 +146,9 @@ class DjangoConnectionField(ConnectionField): after = get_offset_with_default(args.get("after"), -1) + 1 + if max_limit is not None and "first" not in args: + args["first"] = max_limit + connection = connection_from_list_slice( iterable[after:], args, diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 64f54bb..0860a4a 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1127,7 +1127,7 @@ def test_should_return_max_limit(graphene_settings): def test_should_have_next_page(graphene_settings): - graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 6 + graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 4 reporters = [Reporter(**kwargs) for kwargs in REPORTERS] Reporter.objects.bulk_create(reporters) db_reporters = Reporter.objects.all() @@ -1141,9 +1141,6 @@ def test_should_have_next_page(graphene_settings): all_reporters = DjangoConnectionField(ReporterType) schema = graphene.Schema(query=Query) - # Need first: 4 here to trigger the `has_next_page` logic in graphql-relay - # See `arrayconnection.py::connection_from_list_slice`: - # has_next_page=isinstance(first, int) and end_offset < upper_bound query = """ query AllReporters($first: Int, $after: String) { allReporters(first: $first, after: $after) { @@ -1160,7 +1157,7 @@ def test_should_have_next_page(graphene_settings): } """ - result = schema.execute(query, variable_values=dict(first=4)) + result = schema.execute(query, variable_values={}) assert not result.errors assert len(result.data["allReporters"]["edges"]) == 4 assert result.data["allReporters"]["pageInfo"]["hasNextPage"] From 1bec8e44b76e0d830075bca81cb10966fffdbfa3 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Thu, 25 Jun 2020 15:11:18 +0100 Subject: [PATCH 47/79] Move to_const function from Graphene into Graphene-Django (#992) --- graphene_django/converter.py | 3 ++- graphene_django/utils/str_converters.py | 6 ++++++ graphene_django/utils/tests/__init__.py | 0 graphene_django/utils/tests/test_str_converters.py | 10 ++++++++++ setup.py | 1 + 5 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 graphene_django/utils/str_converters.py create mode 100644 graphene_django/utils/tests/__init__.py create mode 100644 graphene_django/utils/tests/test_str_converters.py diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 92963d6..ca524ff 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -20,13 +20,14 @@ from graphene import ( Time, ) from graphene.types.json import JSONString -from graphene.utils.str_converters import to_camel_case, to_const +from graphene.utils.str_converters import to_camel_case from graphql import assert_valid_name from .settings import graphene_settings from .compat import ArrayField, HStoreField, JSONField, RangeField from .fields import DjangoListField, DjangoConnectionField from .utils import import_single_dispatch +from .utils.str_converters import to_const singledispatch = import_single_dispatch() diff --git a/graphene_django/utils/str_converters.py b/graphene_django/utils/str_converters.py new file mode 100644 index 0000000..f41e87a --- /dev/null +++ b/graphene_django/utils/str_converters.py @@ -0,0 +1,6 @@ +import re +from unidecode import unidecode + + +def to_const(string): + return re.sub(r"[\W|^]+", "_", unidecode(string)).upper() diff --git a/graphene_django/utils/tests/__init__.py b/graphene_django/utils/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/graphene_django/utils/tests/test_str_converters.py b/graphene_django/utils/tests/test_str_converters.py new file mode 100644 index 0000000..24064b2 --- /dev/null +++ b/graphene_django/utils/tests/test_str_converters.py @@ -0,0 +1,10 @@ +# coding: utf-8 +from ..str_converters import to_const + + +def test_to_const(): + assert to_const('snakes $1. on a "#plane') == "SNAKES_1_ON_A_PLANE" + + +def test_to_const_unicode(): + assert to_const(u"Skoða þetta unicode stöff") == "SKODA_THETTA_UNICODE_STOFF" diff --git a/setup.py b/setup.py index affaec0..8a070a9 100644 --- a/setup.py +++ b/setup.py @@ -66,6 +66,7 @@ setup( "Django>=1.11", "singledispatch>=3.4.0.3", "promise>=2.1", + "unidecode>=1.1.1,<2", ], setup_requires=["pytest-runner"], tests_require=tests_require, From 8ddad41bb7f536311487f8ededcbfc6dc4ecabac Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Thu, 25 Jun 2020 17:30:05 +0100 Subject: [PATCH 48/79] v2.11.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 dcd0ba7..155a3c6 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.10.1" +__version__ = "2.11.0" __all__ = [ "__version__", From d50955a173782b98c61988a44ce795b757d1c281 Mon Sep 17 00:00:00 2001 From: Thiago Bellini Ribeiro Date: Thu, 9 Jul 2020 14:01:22 -0300 Subject: [PATCH 49/79] Do not break when after is greater than list_length (#999) --- graphene_django/fields.py | 5 +++- graphene_django/tests/test_query.py | 37 +++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 641f423..67559aa 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -144,7 +144,10 @@ class DjangoConnectionField(ConnectionField): min(max_limit, list_length) if max_limit is not None else list_length ) - after = get_offset_with_default(args.get("after"), -1) + 1 + # If after is higher than list_length, connection_from_list_slice + # would try to do a negative slicing which makes django throw an + # AssertionError + after = min(get_offset_with_default(args.get("after"), -1) + 1, list_length) if max_limit is not None and "first" not in args: args["first"] = max_limit diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 0860a4a..3881ed8 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1,4 +1,5 @@ import datetime +import base64 import pytest from django.db import models @@ -1084,6 +1085,42 @@ def test_should_resolve_get_queryset_connectionfields(): assert result.data == expected +def test_connection_should_limit_after_to_list_length(): + reporter_1 = Reporter.objects.create( + first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 + ) + reporter_2 = Reporter.objects.create( + first_name="Some", last_name="Guy", email="someguy@cnn.com", a_choice=1 + ) + + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + + class Query(graphene.ObjectType): + all_reporters = DjangoConnectionField(ReporterType) + + schema = graphene.Schema(query=Query) + query = """ + query ReporterPromiseConnectionQuery ($after: String) { + allReporters(first: 1 after: $after) { + edges { + node { + id + } + } + } + } + """ + + after = base64.b64encode(b"arrayconnection:10").decode() + result = schema.execute(query, variable_values=dict(after=after)) + expected = {"allReporters": {"edges": []}} + assert not result.errors + assert result.data == expected + + REPORTERS = [ dict( first_name="First {}".format(i), From 1205e29bef64d29f21e8a2ae6a4a8d0b33ce5e45 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Thu, 9 Jul 2020 18:02:01 +0100 Subject: [PATCH 50/79] v2.11.1 --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 155a3c6..8e83945 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,7 +1,7 @@ from .fields import DjangoConnectionField, DjangoListField from .types import DjangoObjectType -__version__ = "2.11.0" +__version__ = "2.11.1" __all__ = [ "__version__", From 6aa6aaaa8c0f620986ff072b8e91d08bb7df7066 Mon Sep 17 00:00:00 2001 From: Eric Abruzzese Date: Sun, 12 Jul 2020 09:42:31 -0400 Subject: [PATCH 51/79] Update GraphiQL, add GraphiQL subscription support (#1001) --- docs/index.rst | 1 + docs/settings.rst | 20 +++- docs/subscriptions.rst | 42 ++++++++ graphene_django/settings.py | 2 + .../static/graphene_django/graphiql.js | 97 +++++++++++++++++-- .../templates/graphene/graphiql.html | 9 ++ graphene_django/views.py | 11 ++- 7 files changed, 169 insertions(+), 13 deletions(-) create mode 100644 docs/subscriptions.rst diff --git a/docs/index.rst b/docs/index.rst index be6065c..373969e 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -28,6 +28,7 @@ For more advanced use, check out the Relay tutorial. fields extra-types mutations + subscriptions filtering authorization debug diff --git a/docs/settings.rst b/docs/settings.rst index 5a7e4c9..c2f8600 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -104,7 +104,7 @@ Default: ``100`` ``CAMELCASE_ERRORS`` ------------------------------------- +-------------------- When set to ``True`` field names in the ``errors`` object will be camel case. By default they will be snake case. @@ -151,7 +151,7 @@ Default: ``False`` ``DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME`` --------------------------------------- +---------------------------------------- Define the path of a function that takes the Django choice field and returns a string to completely customise the naming for the Enum type. @@ -170,3 +170,19 @@ Default: ``None`` GRAPHENE = { 'DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME': "myapp.utils.enum_naming" } + + +``SUBSCRIPTION_PATH`` +--------------------- + +Define an alternative URL path where subscription operations should be routed. + +The GraphiQL interface will use this setting to intelligently route subscription operations. This is useful if you have more advanced infrastructure requirements that prevent websockets from being handled at the same path (e.g., a WSGI server listening at ``/graphql`` and an ASGI server listening at ``/ws/graphql``). + +Default: ``None`` + +.. code:: python + + GRAPHENE = { + 'SUBSCRIPTION_PATH': "/ws/graphql" + } diff --git a/docs/subscriptions.rst b/docs/subscriptions.rst new file mode 100644 index 0000000..b1631c3 --- /dev/null +++ b/docs/subscriptions.rst @@ -0,0 +1,42 @@ +Subscriptions +============= + +The ``graphene-django`` project does not currently support GraphQL subscriptions out of the box. However, there are +several community-driven modules for adding subscription support, and the provided GraphiQL interface supports +running subscription operations over a websocket. + +To implement websocket-based support for GraphQL subscriptions, you’ll need to do the following: + +1. Install and configure `django-channels `_. +2. Install and configure* a third-party module for adding subscription support over websockets. A few options include: + + - `graphql-python/graphql-ws `_ + - `datavance/django-channels-graphql-ws `_ + - `jaydenwindle/graphene-subscriptions `_ + +3. Ensure that your application (or at least your GraphQL endpoint) is being served via an ASGI protocol server like + daphne (built in to ``django-channels``), `uvicorn `_, or + `hypercorn `_. + +.. + + *** Note:** By default, the GraphiQL interface that comes with + ``graphene-django`` assumes that you are handling subscriptions at + the same path as any other operation (i.e., you configured both + ``urls.py`` and ``routing.py`` to handle GraphQL operations at the + same path, like ``/graphql``). + + If these URLs differ, GraphiQL will try to run your subscription over + HTTP, which will produce an error. If you need to use a different URL + for handling websocket connections, you can configure + ``SUBSCRIPTION_PATH`` in your ``settings.py``: + + .. code:: python + + GRAPHENE = { + # ... + "SUBSCRIPTION_PATH": "/ws/graphql" # The path you configured in `routing.py`, including a leading slash. + } + +Once your application is properly configured to handle subscriptions, you can use the GraphiQL interface to test +subscriptions like any other operation. diff --git a/graphene_django/settings.py b/graphene_django/settings.py index 666ad8a..52cca89 100644 --- a/graphene_django/settings.py +++ b/graphene_django/settings.py @@ -39,6 +39,8 @@ DEFAULTS = { # Set to True to enable v3 naming convention for choice field Enum's "DJANGO_CHOICE_FIELD_ENUM_V3_NAMING": False, "DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME": None, + # Use a separate path for handling subscriptions. + "SUBSCRIPTION_PATH": None, } if settings.DEBUG: diff --git a/graphene_django/static/graphene_django/graphiql.js b/graphene_django/static/graphene_django/graphiql.js index c939216..1bc3255 100644 --- a/graphene_django/static/graphene_django/graphiql.js +++ b/graphene_django/static/graphene_django/graphiql.js @@ -1,5 +1,13 @@ -(function() { - +(function ( + document, + GRAPHENE_SETTINGS, + GraphiQL, + React, + ReactDOM, + SubscriptionsTransportWs, + history, + location, +) { // Parse the cookie value for a CSRF token var csrftoken; var cookies = ('; ' + document.cookie).split('; csrftoken='); @@ -11,7 +19,7 @@ // Collect the URL parameters var parameters = {}; - window.location.hash.substr(1).split('&').forEach(function (entry) { + location.hash.substr(1).split('&').forEach(function (entry) { var eq = entry.indexOf('='); if (eq >= 0) { parameters[decodeURIComponent(entry.slice(0, eq))] = @@ -41,7 +49,7 @@ var fetchURL = locationQuery(otherParams); // Defines a GraphQL fetcher using the fetch API. - function graphQLFetcher(graphQLParams) { + function httpClient(graphQLParams) { var headers = { 'Accept': 'application/json', 'Content-Type': 'application/json' @@ -64,6 +72,68 @@ } }); } + + // Derive the subscription URL. If the SUBSCRIPTION_URL setting is specified, uses that value. Otherwise + // assumes the current window location with an appropriate websocket protocol. + var subscribeURL = + location.origin.replace(/^http/, "ws") + + (GRAPHENE_SETTINGS.subscriptionPath || location.pathname); + + // Create a subscription client. + var subscriptionClient = new SubscriptionsTransportWs.SubscriptionClient( + subscribeURL, + { + // Reconnect after any interruptions. + reconnect: true, + // Delay socket initialization until the first subscription is started. + lazy: true, + }, + ); + + // Keep a reference to the currently-active subscription, if available. + var activeSubscription = null; + + // Define a GraphQL fetcher that can intelligently route queries based on the operation type. + function graphQLFetcher(graphQLParams) { + var operationType = getOperationType(graphQLParams); + + // If we're about to execute a new operation, and we have an active subscription, + // unsubscribe before continuing. + if (activeSubscription) { + activeSubscription.unsubscribe(); + activeSubscription = null; + } + + if (operationType === "subscription") { + return { + subscribe: function (observer) { + subscriptionClient.request(graphQLParams).subscribe(observer); + activeSubscription = subscriptionClient; + }, + }; + } else { + return httpClient(graphQLParams); + } + } + + // Determine the type of operation being executed for a given set of GraphQL parameters. + function getOperationType(graphQLParams) { + // Run a regex against the query to determine the operation type (query, mutation, subscription). + var operationRegex = new RegExp( + // Look for lines that start with an operation keyword, ignoring whitespace. + "^\\s*(query|mutation|subscription)\\s+" + + // The operation keyword should be followed by the operationName in the GraphQL parameters. + graphQLParams.operationName + + // The line should eventually encounter an opening curly brace. + "[^\\{]*\\{", + // Enable multiline matching. + "m", + ); + var match = operationRegex.exec(graphQLParams.query); + + return match[1]; + } + // When the query and variables string is edited, update the URL bar so // that it can be easily shared. function onEditQuery(newQuery) { @@ -83,10 +153,10 @@ } var options = { fetcher: graphQLFetcher, - onEditQuery: onEditQuery, - onEditVariables: onEditVariables, - onEditOperationName: onEditOperationName, - query: parameters.query, + onEditQuery: onEditQuery, + onEditVariables: onEditVariables, + onEditOperationName: onEditOperationName, + query: parameters.query, } if (parameters.variables) { options.variables = parameters.variables; @@ -99,4 +169,13 @@ React.createElement(GraphiQL, options), document.getElementById("editor") ); -})(); +})( + document, + window.GRAPHENE_SETTINGS, + window.GraphiQL, + window.React, + window.ReactDOM, + window.SubscriptionsTransportWs, + window.history, + window.location, +); diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index d0546bd..20ac1d0 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -29,10 +29,19 @@ add "&raw" to the end of the URL within a browser. crossorigin="anonymous"> +
{% csrf_token %} + diff --git a/graphene_django/views.py b/graphene_django/views.py index 16bf34b..22b4864 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -52,9 +52,10 @@ def instantiate_middleware(middlewares): class GraphQLView(View): - graphiql_version = "0.14.0" + graphiql_version = "1.0.3" graphiql_template = "graphene/graphiql.html" - react_version = "16.8.6" + react_version = "16.13.1" + subscriptions_transport_ws_version = "0.9.16" schema = None graphiql = False @@ -64,6 +65,7 @@ class GraphQLView(View): root_value = None pretty = False batch = False + subscription_path = None def __init__( self, @@ -75,6 +77,7 @@ class GraphQLView(View): pretty=False, batch=False, backend=None, + subscription_path=None, ): if not schema: schema = graphene_settings.SCHEMA @@ -97,6 +100,8 @@ class GraphQLView(View): self.graphiql = self.graphiql or graphiql self.batch = self.batch or batch self.backend = backend + if subscription_path is None: + subscription_path = graphene_settings.SUBSCRIPTION_PATH assert isinstance( self.schema, GraphQLSchema @@ -134,6 +139,8 @@ class GraphQLView(View): request, graphiql_version=self.graphiql_version, react_version=self.react_version, + subscriptions_transport_ws_version=self.subscriptions_transport_ws_version, + subscription_path=self.subscription_path, ) if self.batch: From 057b49117600fc65420dda13893297e3ee83ae69 Mon Sep 17 00:00:00 2001 From: Eric Abruzzese Date: Sun, 12 Jul 2020 15:48:12 -0400 Subject: [PATCH 52/79] GraphiQL cleanup (#1002) * Add integrity checks for GraphiQL CDN resources Also fixes an erroneous assignment preventing a setting from getting to the UI. * Pass SRIs and new versions to the template * Update hashes * Use SRI-stable artifacts for GraphiQL resources --- .../static/graphene_django/graphiql.js | 76 +++++++++++-------- .../templates/graphene/graphiql.html | 11 ++- graphene_django/views.py | 34 ++++++++- 3 files changed, 84 insertions(+), 37 deletions(-) diff --git a/graphene_django/static/graphene_django/graphiql.js b/graphene_django/static/graphene_django/graphiql.js index 1bc3255..17836ef 100644 --- a/graphene_django/static/graphene_django/graphiql.js +++ b/graphene_django/static/graphene_django/graphiql.js @@ -1,43 +1,55 @@ (function ( document, + GRAPHENE_SETTINGS, GraphiQL, React, ReactDOM, SubscriptionsTransportWs, + fetch, history, location, ) { // Parse the cookie value for a CSRF token var csrftoken; - var cookies = ('; ' + document.cookie).split('; csrftoken='); + var cookies = ("; " + document.cookie).split("; csrftoken="); if (cookies.length == 2) { - csrftoken = cookies.pop().split(';').shift(); + csrftoken = cookies.pop().split(";").shift(); } else { csrftoken = document.querySelector("[name=csrfmiddlewaretoken]").value; } // Collect the URL parameters var parameters = {}; - location.hash.substr(1).split('&').forEach(function (entry) { - var eq = entry.indexOf('='); - if (eq >= 0) { - parameters[decodeURIComponent(entry.slice(0, eq))] = - decodeURIComponent(entry.slice(eq + 1)); - } - }); + location.hash + .substr(1) + .split("&") + .forEach(function (entry) { + var eq = entry.indexOf("="); + if (eq >= 0) { + parameters[decodeURIComponent(entry.slice(0, eq))] = decodeURIComponent( + entry.slice(eq + 1), + ); + } + }); // Produce a Location fragment string from a parameter object. function locationQuery(params) { - return '#' + Object.keys(params).map(function (key) { - return encodeURIComponent(key) + '=' + - encodeURIComponent(params[key]); - }).join('&'); + return ( + "#" + + Object.keys(params) + .map(function (key) { + return ( + encodeURIComponent(key) + "=" + encodeURIComponent(params[key]) + ); + }) + .join("&") + ); } // Derive a fetch URL from the current URL, sans the GraphQL parameters. var graphqlParamNames = { query: true, variables: true, - operationName: true + operationName: true, }; var otherParams = {}; for (var k in parameters) { @@ -51,26 +63,28 @@ // Defines a GraphQL fetcher using the fetch API. function httpClient(graphQLParams) { var headers = { - 'Accept': 'application/json', - 'Content-Type': 'application/json' + Accept: "application/json", + "Content-Type": "application/json", }; if (csrftoken) { - headers['X-CSRFToken'] = csrftoken; + headers["X-CSRFToken"] = csrftoken; } return fetch(fetchURL, { - method: 'post', + method: "post", headers: headers, body: JSON.stringify(graphQLParams), - credentials: 'include', - }).then(function (response) { - return response.text(); - }).then(function (responseBody) { - try { - return JSON.parse(responseBody); - } catch (error) { - return responseBody; - } - }); + credentials: "include", + }) + .then(function (response) { + return response.text(); + }) + .then(function (responseBody) { + try { + return JSON.parse(responseBody); + } catch (error) { + return responseBody; + } + }); } // Derive the subscription URL. If the SUBSCRIPTION_URL setting is specified, uses that value. Otherwise @@ -157,7 +171,7 @@ onEditVariables: onEditVariables, onEditOperationName: onEditOperationName, query: parameters.query, - } + }; if (parameters.variables) { options.variables = parameters.variables; } @@ -167,15 +181,17 @@ // Render into the body. ReactDOM.render( React.createElement(GraphiQL, options), - document.getElementById("editor") + document.getElementById("editor"), ); })( document, + window.GRAPHENE_SETTINGS, window.GraphiQL, window.React, window.ReactDOM, window.SubscriptionsTransportWs, + window.fetch, window.history, window.location, ); diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index 20ac1d0..abc4b52 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -17,19 +17,24 @@ add "&raw" to the end of the URL within a browser. width: 100%; } - - diff --git a/graphene_django/views.py b/graphene_django/views.py index 22b4864..59084e8 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -52,10 +52,27 @@ def instantiate_middleware(middlewares): class GraphQLView(View): - graphiql_version = "1.0.3" graphiql_template = "graphene/graphiql.html" + + # Polyfill for window.fetch. + whatwg_fetch_version = "3.2.0" + whatwg_fetch_sri = "sha256-l6HCB9TT2v89oWbDdo2Z3j+PSVypKNLA/nqfzSbM8mo=" + + # React and ReactDOM. react_version = "16.13.1" - subscriptions_transport_ws_version = "0.9.16" + react_sri = "sha256-yUhvEmYVhZ/GGshIQKArLvySDSh6cdmdcIx0spR3UP4=" + react_dom_sri = "sha256-vFt3l+illeNlwThbDUdoPTqF81M8WNSZZZt3HEjsbSU=" + + # The GraphiQL React app. + graphiql_version = "1.0.3" + graphiql_sri = "sha256-VR4buIDY9ZXSyCNFHFNik6uSe0MhigCzgN4u7moCOTk=" + graphiql_css_sri = "sha256-LwqxjyZgqXDYbpxQJ5zLQeNcf7WVNSJ+r8yp2rnWE/E=" + + # The websocket transport library for subscriptions. + subscriptions_transport_ws_version = "0.9.17" + subscriptions_transport_ws_sri = ( + "sha256-kCDzver8iRaIQ/SVlfrIwxaBQ/avXf9GQFJRLlErBnk=" + ) schema = None graphiql = False @@ -101,7 +118,7 @@ class GraphQLView(View): self.batch = self.batch or batch self.backend = backend if subscription_path is None: - subscription_path = graphene_settings.SUBSCRIPTION_PATH + self.subscription_path = graphene_settings.SUBSCRIPTION_PATH assert isinstance( self.schema, GraphQLSchema @@ -137,9 +154,18 @@ class GraphQLView(View): if show_graphiql: return self.render_graphiql( request, - graphiql_version=self.graphiql_version, + # Dependency parameters. + whatwg_fetch_version=self.whatwg_fetch_version, + whatwg_fetch_sri=self.whatwg_fetch_sri, react_version=self.react_version, + react_sri=self.react_sri, + react_dom_sri=self.react_dom_sri, + graphiql_version=self.graphiql_version, + graphiql_sri=self.graphiql_sri, + graphiql_css_sri=self.graphiql_css_sri, subscriptions_transport_ws_version=self.subscriptions_transport_ws_version, + subscriptions_transport_ws_sri=self.subscriptions_transport_ws_sri, + # The SUBSCRIPTION_PATH setting. subscription_path=self.subscription_path, ) From e439bf3727d44b0d49d6cd5f5f6758fe2dd47bec Mon Sep 17 00:00:00 2001 From: Mel van Londen Date: Sun, 12 Jul 2020 13:17:03 -0700 Subject: [PATCH 53/79] bump version to 2.12.0 --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 8e83945..49f480d 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,7 +1,7 @@ from .fields import DjangoConnectionField, DjangoListField from .types import DjangoObjectType -__version__ = "2.11.1" +__version__ = "2.12.0" __all__ = [ "__version__", From 63cfbbf59aaf9a20015194999e95b3fa5f82b6e7 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Mon, 13 Jul 2020 22:09:52 +0100 Subject: [PATCH 54/79] Remove operation name from the regex and default to query (#1004) --- graphene_django/static/graphene_django/graphiql.js | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/graphene_django/static/graphene_django/graphiql.js b/graphene_django/static/graphene_django/graphiql.js index 17836ef..45f8ad7 100644 --- a/graphene_django/static/graphene_django/graphiql.js +++ b/graphene_django/static/graphene_django/graphiql.js @@ -135,15 +135,18 @@ // Run a regex against the query to determine the operation type (query, mutation, subscription). var operationRegex = new RegExp( // Look for lines that start with an operation keyword, ignoring whitespace. - "^\\s*(query|mutation|subscription)\\s+" + - // The operation keyword should be followed by the operationName in the GraphQL parameters. - graphQLParams.operationName + + "^\\s*(query|mutation|subscription)\\s*" + + // The operation keyword should be followed by whitespace and the operationName in the GraphQL parameters (if available). + (graphQLParams.operationName ? ("\\s+" + graphQLParams.operationName) : "") + // The line should eventually encounter an opening curly brace. "[^\\{]*\\{", // Enable multiline matching. "m", ); var match = operationRegex.exec(graphQLParams.query); + if (!match) { + return "query"; + } return match[1]; } From b552dcac24364d3ef824f865ba419c74605942b2 Mon Sep 17 00:00:00 2001 From: Mel van Londen Date: Mon, 13 Jul 2020 14:12:42 -0700 Subject: [PATCH 55/79] bump version number --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 49f480d..f94b5be 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,7 +1,7 @@ from .fields import DjangoConnectionField, DjangoListField from .types import DjangoObjectType -__version__ = "2.12.0" +__version__ = "2.12.1" __all__ = [ "__version__", From 97de26bf2e5c38f998cc5e2fb3086345be8f0161 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Wed, 5 Aug 2020 20:17:53 +0100 Subject: [PATCH 56/79] Update tutorial docs (#994) --- docs/installation.rst | 8 +- docs/queries.rst | 151 +++++++++++++++++--------- docs/tutorial-plain.rst | 230 +++++++++++++--------------------------- docs/tutorial-relay.rst | 2 + 4 files changed, 177 insertions(+), 214 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index 048a994..35272b0 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -25,8 +25,8 @@ Add ``graphene_django`` to the ``INSTALLED_APPS`` in the ``settings.py`` file of INSTALLED_APPS = [ ... - 'django.contrib.staticfiles', # Required for GraphiQL - 'graphene_django' + "django.contrib.staticfiles", # Required for GraphiQL + "graphene_django" ] @@ -63,7 +63,7 @@ Finally, define the schema location for Graphene in the ``settings.py`` file of .. code:: python GRAPHENE = { - 'SCHEMA': 'django_root.schema.schema' + "SCHEMA": "django_root.schema.schema" } Where ``path.schema.schema`` is the location of the ``Schema`` object in your Django project. @@ -75,7 +75,7 @@ The most basic ``schema.py`` looks like this: import graphene class Query(graphene.ObjectType): - pass + hello = graphene.String(default_value="Hi!") schema = graphene.Schema(query=Query) diff --git a/docs/queries.rst b/docs/queries.rst index 4b3f718..02a2bf2 100644 --- a/docs/queries.rst +++ b/docs/queries.rst @@ -20,27 +20,26 @@ Full example # my_app/schema.py import graphene + from graphene_django import DjangoObjectType - from graphene_django.types import DjangoObjectType from .models import Question - class QuestionType(DjangoObjectType): class Meta: model = Question + fields = ("id", "question_text") - - class Query: + class Query(graphene.ObjectType): questions = graphene.List(QuestionType) - question = graphene.Field(QuestionType, question_id=graphene.String()) + question_by_id = graphene.Field(QuestionType, id=graphene.String()) - def resolve_questions(self, info, **kwargs): + def resolve_questions(root, info, **kwargs): # Querying a list return Question.objects.all() - def resolve_question(self, info, question_id): + def resolve_question_by_id(root, info, id): # Querying a single question - return Question.objects.get(pk=question_id) + return Question.objects.get(pk=id) Specifying which fields to include @@ -60,21 +59,27 @@ Show **only** these fields on the model: .. code:: python + from graphene_django import DjangoObjectType + from .models import Question + class QuestionType(DjangoObjectType): class Meta: model = Question - fields = ('id', 'question_text') + fields = ("id", "question_text") -You can also set the ``fields`` attribute to the special value ``'__all__'`` to indicate that all fields in the model should be used. +You can also set the ``fields`` attribute to the special value ``"__all__"`` to indicate that all fields in the model should be used. For example: .. code:: python + from graphene_django import DjangoObjectType + from .models import Question + class QuestionType(DjangoObjectType): class Meta: model = Question - fields = '__all__' + fields = "__all__" ``exclude`` @@ -84,10 +89,13 @@ Show all fields **except** those in ``exclude``: .. code:: python + from graphene_django import DjangoObjectType + from .models import Question + class QuestionType(DjangoObjectType): class Meta: model = Question - exclude = ('question_text',) + exclude = ("question_text",) Customising fields @@ -97,16 +105,19 @@ You can completely overwrite a field, or add new fields, to a ``DjangoObjectType .. code:: python + from graphene_django import DjangoObjectType + from .models import Question + class QuestionType(DjangoObjectType): class Meta: model = Question - fields = ('id', 'question_text') + fields = ("id", "question_text") extra_field = graphene.String() def resolve_extra_field(self, info): - return 'hello!' + return "hello!" Choices to Enum conversion @@ -121,12 +132,19 @@ For example the following ``Model`` and ``DjangoObjectType``: .. code:: python - class PetModel(models.Model): - kind = models.CharField(max_length=100, choices=(('cat', 'Cat'), ('dog', 'Dog'))) + from django.db import models + from graphene_django import DjangoObjectType - class Pet(DjangoObjectType): - class Meta: - model = PetModel + class PetModel(models.Model): + kind = models.CharField( + max_length=100, + choices=(("cat", "Cat"), ("dog", "Dog")) + ) + + class Pet(DjangoObjectType): + class Meta: + model = PetModel + fields = ("id", "kind",) Results in the following GraphQL schema definition: @@ -148,27 +166,35 @@ You can disable this automatic conversion by setting .. code:: python - class Pet(DjangoObjectType): - class Meta: - model = PetModel - convert_choices_to_enum = False + from graphene_django import DjangoObjectType + from .models import PetModel + + class Pet(DjangoObjectType): + class Meta: + model = PetModel + fields = ("id", "kind",) + convert_choices_to_enum = False .. code:: - type Pet { - id: ID! - kind: String! - } + type Pet { + id: ID! + kind: String! + } You can also set ``convert_choices_to_enum`` to a list of fields that should be automatically converted into enums: .. code:: python - class Pet(DjangoObjectType): - class Meta: - model = PetModel - convert_choices_to_enum = ['kind'] + from graphene_django import DjangoObjectType + from .models import PetModel + + class Pet(DjangoObjectType): + class Meta: + model = PetModel + fields = ("id", "kind",) + convert_choices_to_enum = ["kind"] **Note:** Setting ``convert_choices_to_enum = []`` is the same as setting it to ``False``. @@ -181,6 +207,8 @@ Say you have the following models: .. code:: python + from django.db import models + class Category(models.Model): foo = models.CharField(max_length=256) @@ -192,10 +220,13 @@ When ``Question`` is published as a ``DjangoObjectType`` and you want to add ``C .. code:: python + from graphene_django import DjangoObjectType + from .models import Question + class QuestionType(DjangoObjectType): class Meta: model = Question - fields = ('category',) + fields = ("category",) Then all query-able related models must be defined as DjangoObjectType subclass, or they will fail to show if you are trying to query those relation fields. You only @@ -203,9 +234,13 @@ need to create the most basic class for this to work: .. code:: python + from graphene_django import DjangoObjectType + from .models import Category + class CategoryType(DjangoObjectType): class Meta: model = Category + fields = ("foo",) .. _django-objecttype-get-queryset: @@ -220,7 +255,6 @@ Use this to control filtering on the ObjectType level instead of the Query objec from graphene_django.types import DjangoObjectType from .models import Question - class QuestionType(DjangoObjectType): class Meta: model = Question @@ -240,18 +274,22 @@ This resolve method should follow this format: .. code:: python - def resolve_foo(self, info, **kwargs): + def resolve_foo(parent, info, **kwargs): Where "foo" is the name of the field declared in the ``Query`` object. .. code:: python - class Query: + import graphene + from .models import Question + from .types import QuestionType + + class Query(graphene.ObjectType): foo = graphene.List(QuestionType) - def resolve_foo(self, info, **kwargs): - id = kwargs.get('id') - return QuestionModel.objects.get(id) + def resolve_foo(root, info): + id = kwargs.get("id") + return Question.objects.get(id) Arguments ~~~~~~~~~ @@ -260,10 +298,18 @@ Additionally, Resolvers will receive **any arguments declared in the field defin .. code:: python - class Query: - question = graphene.Field(Question, foo=graphene.String(), bar=graphene.Int()) + import graphene + from .models import Question + from .types import QuestionType - def resolve_question(self, info, foo, bar): + class Query(graphene.ObjectType): + question = graphene.Field( + QuestionType, + foo=graphene.String(), + bar=graphene.Int() + ) + + def resolve_question(root, info, foo, bar): # If `foo` or `bar` are declared in the GraphQL query they will be here, else None. return Question.objects.filter(foo=foo, bar=bar).first() @@ -278,7 +324,15 @@ of Django's ``HTTPRequest`` in your resolve methods, such as checking for authen .. code:: python - def resolve_questions(self, info, **kwargs): + import graphene + + from .models import Question + from .types import QuestionType + + class Query(graphene.ObjectType): + questions = graphene.List(QuestionType) + + def resolve_questions(root, info): # See if a user is authenticated if info.context.user.is_authenticated(): return Question.objects.all() @@ -305,15 +359,13 @@ Django models and your external API. import graphene from .models import Question - class MyQuestion(graphene.ObjectType): text = graphene.String() - - class Query: + class Query(graphene.ObjectType): question = graphene.Field(MyQuestion, question_id=graphene.String()) - def resolve_question(self, info, question_id): + def resolve_question(root, info, question_id): question = Question.objects.get(pk=question_id) return MyQuestion( text=question.question_text @@ -343,25 +395,22 @@ the core graphene pages for more information on customizing the Relay experience from graphene_django import DjangoObjectType from .models import Question - class QuestionType(DjangoObjectType): class Meta: model = Question - interfaces = (relay.Node,) - + interfaces = (relay.Node,) # make sure you add this + fields = "__all__" class QuestionConnection(relay.Connection): class Meta: node = QuestionType - class Query: questions = relay.ConnectionField(QuestionConnection) def resolve_questions(root, info, **kwargs): return Question.objects.all() - You can now execute queries like: diff --git a/docs/tutorial-plain.rst b/docs/tutorial-plain.rst index e80f9ab..45927a5 100644 --- a/docs/tutorial-plain.rst +++ b/docs/tutorial-plain.rst @@ -3,15 +3,11 @@ Basic Tutorial Graphene Django has a number of additional features that are designed to make working with Django easy. Our primary focus in this tutorial is to give a good -understanding of how to connect models from Django ORM to graphene object types. +understanding of how to connect models from Django ORM to Graphene object types. Set up the Django project ------------------------- -You can find the entire project in ``examples/cookbook-plain``. - ----- - We will set up the project, create the following: - A Django project called ``cookbook`` @@ -28,13 +24,12 @@ We will set up the project, create the following: source env/bin/activate # On Windows use `env\Scripts\activate` # Install Django and Graphene with Django support - pip install django - pip install graphene_django + pip install django graphene_django # Set up a new project with a single application - django-admin.py startproject cookbook . # Note the trailing '.' character + django-admin startproject cookbook . # Note the trailing '.' character cd cookbook - django-admin.py startapp ingredients + django-admin startapp ingredients Now sync your database for the first time: @@ -54,19 +49,18 @@ Let's get started with these models: # cookbook/ingredients/models.py from django.db import models - class Category(models.Model): name = models.CharField(max_length=100) def __str__(self): return self.name - class Ingredient(models.Model): name = models.CharField(max_length=100) notes = models.TextField() category = models.ForeignKey( - Category, related_name='ingredients', on_delete=models.CASCADE) + Category, related_name="ingredients", on_delete=models.CASCADE + ) def __str__(self): return self.name @@ -75,10 +69,12 @@ Add ingredients as INSTALLED_APPS: .. code:: python + # cookbook/settings.py + INSTALLED_APPS = [ ... # Install the ingredients app - 'cookbook.ingredients', + "cookbook.ingredients", ] @@ -102,13 +98,13 @@ following: .. code:: bash - $ python ./manage.py loaddata ingredients + python manage.py loaddata ingredients Installed 6 object(s) from 1 fixture(s) Alternatively you can use the Django admin interface to create some data yourself. You'll need to run the development server (see below), and -create a login for yourself too (``./manage.py createsuperuser``). +create a login for yourself too (``python manage.py createsuperuser``). Register models with admin panel: @@ -138,66 +134,48 @@ order to create this representation, Graphene needs to know about each This graph also has a *root type* through which all access begins. This is the ``Query`` class below. -This means, for each of our models, we are going to create a type, subclassing ``DjangoObjectType`` +To create GraphQL types for each of our Django models, we are going to subclass the ``DjangoObjectType`` class which will automatically define GraphQL fields that correspond to the fields on the Django models. After we've done that, we will list those types as fields in the ``Query`` class. -Create ``cookbook/ingredients/schema.py`` and type the following: +Create ``cookbook/schema.py`` and type the following: .. code:: python - # cookbook/ingredients/schema.py + # cookbook/schema.py import graphene - - from graphene_django.types import DjangoObjectType + from graphene_django import DjangoObjectType from cookbook.ingredients.models import Category, Ingredient - class CategoryType(DjangoObjectType): class Meta: model = Category - + fields = ("id", "name", "ingredients") class IngredientType(DjangoObjectType): class Meta: model = Ingredient + fields = ("id", "name", "notes", "category") - - class Query(object): - all_categories = graphene.List(CategoryType) + class Query(graphene.ObjectType): all_ingredients = graphene.List(IngredientType) + category_by_name = graphene.Field(CategoryType, name=graphene.String(required=True)) - def resolve_all_categories(self, info, **kwargs): - return Category.objects.all() - - def resolve_all_ingredients(self, info, **kwargs): + def resolve_all_ingredients(root, info): # We can easily optimize query count in the resolve method - return Ingredient.objects.select_related('category').all() + return Ingredient.objects.select_related("category").all() - -Note that the above ``Query`` class is a mixin, inheriting from -``object``. This is because we will now create a project-level query -class which will combine all our app-level mixins. - -Create the parent project-level ``cookbook/schema.py``: - -.. code:: python - - import graphene - - import cookbook.ingredients.schema - - - class Query(cookbook.ingredients.schema.Query, graphene.ObjectType): - # This class will inherit from multiple Queries - # as we begin to add more apps to our project - pass + def resolve_category_by_name(root, info, name): + try: + return Category.objects.get(name=name) + except Category.DoesNotExist: + return None schema = graphene.Schema(query=Query) You can think of this as being something like your top-level ``urls.py`` -file (although it currently lacks any namespacing). +file. Testing everything so far ------------------------- @@ -216,18 +194,21 @@ Add ``graphene_django`` to ``INSTALLED_APPS`` in ``cookbook/settings.py``: .. code:: python + # cookbook/settings.py + INSTALLED_APPS = [ ... - # This will also make the `graphql_schema` management command available - 'graphene_django', + "graphene_django", ] And then add the ``SCHEMA`` to the ``GRAPHENE`` config in ``cookbook/settings.py``: .. code:: python + # cookbook/settings.py + GRAPHENE = { - 'SCHEMA': 'cookbook.schema.schema' + "SCHEMA": "cookbook.schema.schema" } Alternatively, we can specify the schema to be used in the urls definition, @@ -245,14 +226,17 @@ aforementioned GraphiQL we specify that on the parameters with ``graphiql=True`` .. code:: python - from django.conf.urls import url, include + # cookbook/urls.py + from django.contrib import admin + from django.urls import path + from django.views.decorators.csrf import csrf_exempt from graphene_django.views import GraphQLView urlpatterns = [ - url(r'^admin/', admin.site.urls), - url(r'^graphql$', GraphQLView.as_view(graphiql=True)), + path("admin/", admin.site.urls), + path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True))), ] @@ -261,16 +245,19 @@ as explained above, we can do so here using: .. code:: python - from django.conf.urls import url, include + # cookbook/urls.py + from django.contrib import admin + from django.urls import path + from django.views.decorators.csrf import csrf_exempt from graphene_django.views import GraphQLView from cookbook.schema import schema urlpatterns = [ - url(r'^admin/', admin.site.urls), - url(r'^graphql$', GraphQLView.as_view(graphiql=True, schema=schema)), + path("admin/", admin.site.urls), + path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema))), ] @@ -283,10 +270,10 @@ from the command line. .. code:: bash - $ python ./manage.py runserver + python manage.py runserver Performing system checks... - Django version 1.11, using settings 'cookbook.settings' + Django version 3.0.7, using settings 'cookbook.settings' Starting development server at http://127.0.0.1:8000/ Quit the server with CONTROL-C. @@ -329,24 +316,25 @@ If you are using the provided fixtures, you will see the following response: } } -You can experiment with ``allCategories`` too. -Something to have in mind is the `auto camelcasing `__ that is happening. +Congratulations, you have created a working GraphQL server 🥳! + +Note: Graphene `automatically camelcases `__ all field names for better compatibility with JavaScript clients. Getting relations ----------------- -Right now, with this simple setup in place, we can query for relations too. This is where graphql becomes really powerful! +Using the current schema we can query for relations too. This is where GraphQL becomes really powerful! -For example, we may want to list all categories and in each category, all ingredients that are in that category. +For example, we may want to get a specific categories and list all ingredients that are in that category. We can do that with the following query: .. code:: query { - allCategories { + categoryByName(name: "Dairy") { id name ingredients { @@ -356,43 +344,26 @@ We can do that with the following query: } } - This will give you (in case you are using the fixtures) the following result: .. code:: { "data": { - "allCategories": [ - { - "id": "1", - "name": "Dairy", - "ingredients": [ - { - "id": "1", - "name": "Eggs" - }, - { - "id": "2", - "name": "Milk" - } - ] - }, - { - "id": "2", - "name": "Meat", - "ingredients": [ - { - "id": "3", - "name": "Beef" - }, - { - "id": "4", - "name": "Chicken" - } - ] - } - ] + "categoryByName": { + "id": "1", + "name": "Dairy", + "ingredients": [ + { + "id": "1", + "name": "Eggs" + }, + { + "id": "2", + "name": "Milk" + } + ] + } } } @@ -411,71 +382,12 @@ We can also list all ingredients and get information for the category they are i } } -Getting single objects ----------------------- - -So far, we have been able to fetch list of objects and follow relation. But what about single objects? - -We can update our schema to support that, by adding new query for ``ingredient`` and ``category`` and adding arguments, so we can query for specific objects. -Add the **Highlighted** lines to ``cookbook/ingredients/schema.py`` - -.. literalinclude:: schema.py - :emphasize-lines: 19-21,25-27,36-58 - -Now, with the code in place, we can query for single objects. - -For example, lets query ``category``: - - -.. code:: - - query { - category(id: 1) { - name - } - anotherCategory: category(name: "Dairy") { - ingredients { - id - name - } - } - } - -This will give us the following results: - -.. code:: - - { - "data": { - "category": { - "name": "Dairy" - }, - "anotherCategory": { - "ingredients": [ - { - "id": "1", - "name": "Eggs" - }, - { - "id": "2", - "name": "Milk" - } - ] - } - } - } - -As an exercise, you can try making some queries to ``ingredient``. - -Something to keep in mind - since we are using one field several times in our query, we need `aliases `__ - - Summary ------- -As you can see, GraphQL is very powerful but there are a lot of repetitions in our example. We can do a lot of improvements by adding layers of abstraction on top of ``graphene-django``. +As you can see, GraphQL is very powerful and integrating Django models allows you to get started with a working server quickly. -If you want to put things like ``django-filter`` and automatic pagination in action, you should continue with the **relay tutorial.** +If you want to put things like ``django-filter`` and automatic pagination in action, you should continue with the :ref:`Relay tutorial`. -A good idea is to check the `graphene `__ -documentation but it is not essential to understand and use Graphene-Django in your project. \ No newline at end of file +A good idea is to check the `Graphene `__ +documentation so that you are familiar with it as well. diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst index e900ea1..94f1aa7 100644 --- a/docs/tutorial-relay.rst +++ b/docs/tutorial-relay.rst @@ -1,3 +1,5 @@ +.. _Relay tutorial: + Relay tutorial ======================================== From 2308965658f34a059f59b8c0c4fa786a6adeea7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolai=20R=C3=B8ed=20Kristiansen?= Date: Wed, 5 Aug 2020 21:24:16 +0200 Subject: [PATCH 57/79] Extract query function from GraphQLTestCase making it possible to use in a pytest fixture (#1015) --- docs/testing.rst | 42 +++++++++++-- graphene_django/tests/test_utils.py | 27 ++++++++ graphene_django/utils/testing.py | 97 ++++++++++++++++++++--------- 3 files changed, 132 insertions(+), 34 deletions(-) diff --git a/docs/testing.rst b/docs/testing.rst index 473a9ba..23acef2 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -1,6 +1,9 @@ Testing API calls with django ============================= +Using unittest +-------------- + If you want to unittest your API calls derive your test case from the class `GraphQLTestCase`. Your endpoint is set through the `GRAPHQL_URL` attribute on `GraphQLTestCase`. The default endpoint is `GRAPHQL_URL = "/graphql/"`. @@ -12,12 +15,8 @@ Usage: import json from graphene_django.utils.testing import GraphQLTestCase - from my_project.config.schema import schema class MyFancyTestCase(GraphQLTestCase): - # Here you need to inject your test case's schema - GRAPHQL_SCHEMA = schema - def test_some_query(self): response = self.query( ''' @@ -82,3 +81,38 @@ Usage: # Add some more asserts if you like ... + +Using pytest +------------ + +To use pytest define a simple fixture using the query helper below + +.. code:: python + + # Create a fixture using the graphql_query helper and `client` fixture from `pytest-django`. + import pytest + from graphene_django.utils.testing import graphql_query + + @pytest.fixture + def client_query(client) + def func(*args, **kwargs): + return graphql_query(*args, **kwargs, client=client) + + return func + + # Test you query using the client_query fixture + def test_some_query(client_query): + response = graphql_query( + ''' + query { + myModel { + id + name + } + } + ''', + op_name='myModel' + ) + + content = json.loads(response.content) + assert 'errors' not in content \ No newline at end of file diff --git a/graphene_django/tests/test_utils.py b/graphene_django/tests/test_utils.py index c0d376b..f5a8b05 100644 --- a/graphene_django/tests/test_utils.py +++ b/graphene_django/tests/test_utils.py @@ -6,6 +6,7 @@ from mock import patch from ..utils import camelize, get_model_fields, GraphQLTestCase from .models import Film, Reporter +from ..utils.testing import graphql_query def test_get_model_fields_no_duplication(): @@ -58,3 +59,29 @@ def test_graphql_test_case_op_name(post_mock): "operationName", "QueryName", ) in body.items(), "Field 'operationName' is not present in the final request." + + +@pytest.mark.django_db +@patch("graphene_django.utils.testing.Client.post") +def test_graphql_query_case_op_name(post_mock): + graphql_query("query { }", op_name="QueryName") + body = json.loads(post_mock.call_args.args[1]) + # `operationName` field from https://graphql.org/learn/serving-over-http/#post-request + assert ( + "operationName", + "QueryName", + ) in body.items(), "Field 'operationName' is not present in the final request." + + +@pytest.fixture +def client_query(client): + def func(*args, **kwargs): + return graphql_query(*args, client=client, **kwargs) + + return func + + +def test_pytest_fixture_usage(client_query): + response = graphql_query("query { test }") + content = json.loads(response.content) + assert content == {"data": {"test": "Hello World"}} diff --git a/graphene_django/utils/testing.py b/graphene_django/utils/testing.py index 0f68a51..6b2d3e8 100644 --- a/graphene_django/utils/testing.py +++ b/graphene_django/utils/testing.py @@ -2,6 +2,63 @@ import json from django.test import TestCase, Client +DEFAULT_GRAPHQL_URL = "/graphql/" + + +def graphql_query( + query, + op_name=None, + input_data=None, + variables=None, + headers=None, + client=None, + graphql_url=None, +): + """ + Args: + query (string) - GraphQL query to run + op_name (string) - If the query is a mutation or named query, you must + supply the op_name. For annon queries ("{ ... }"), + should be None (default). + input_data (dict) - If provided, the $input variable in GraphQL will be set + to this value. If both ``input_data`` and ``variables``, + are provided, the ``input`` field in the ``variables`` + dict will be overwritten with this value. + variables (dict) - If provided, the "variables" field in GraphQL will be + set to this value. + headers (dict) - If provided, the headers in POST request to GRAPHQL_URL + will be set to this value. + client (django.test.Client) - Test client. Defaults to django.test.Client. + graphql_url (string) - URL to graphql endpoint. Defaults to "/graphql". + + Returns: + Response object from client + """ + if client is None: + client = Client() + if not graphql_url: + graphql_url = DEFAULT_GRAPHQL_URL + + body = {"query": query} + if op_name: + body["operationName"] = op_name + if variables: + body["variables"] = variables + if input_data: + if variables in body: + body["variables"]["input"] = input_data + else: + body["variables"] = {"input": input_data} + if headers: + resp = client.post( + graphql_url, json.dumps(body), content_type="application/json", **headers + ) + else: + resp = client.post( + graphql_url, json.dumps(body), content_type="application/json" + ) + return resp + class GraphQLTestCase(TestCase): """ @@ -9,19 +66,12 @@ class GraphQLTestCase(TestCase): """ # URL to graphql endpoint - GRAPHQL_URL = "/graphql/" - # Here you need to set your graphql schema for the tests - GRAPHQL_SCHEMA = None + GRAPHQL_URL = DEFAULT_GRAPHQL_URL @classmethod def setUpClass(cls): super(GraphQLTestCase, cls).setUpClass() - if not cls.GRAPHQL_SCHEMA: - raise AttributeError( - "Variable GRAPHQL_SCHEMA not defined in GraphQLTestCase." - ) - cls._client = Client() def query(self, query, op_name=None, input_data=None, variables=None, headers=None): @@ -43,28 +93,15 @@ class GraphQLTestCase(TestCase): Returns: Response object from client """ - body = {"query": query} - if op_name: - body["operationName"] = op_name - if variables: - body["variables"] = variables - if input_data: - if variables in body: - body["variables"]["input"] = input_data - else: - body["variables"] = {"input": input_data} - if headers: - resp = self._client.post( - self.GRAPHQL_URL, - json.dumps(body), - content_type="application/json", - **headers - ) - else: - resp = self._client.post( - self.GRAPHQL_URL, json.dumps(body), content_type="application/json" - ) - return resp + return graphql_query( + query, + op_name=op_name, + input_data=input_data, + variables=variables, + headers=headers, + client=self._client, + graphql_url=self.GRAPHQL_URL, + ) def assertResponseNoErrors(self, resp): """ From 55769e814f3fc3da6c6d39696d6d1460fd8c9c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rados=C5=82aw=20Kowalski?= Date: Fri, 7 Aug 2020 11:13:26 +0200 Subject: [PATCH 58/79] Add headers support to GraphiQL (#1016) Co-authored-by: Jonathan Kim --- docs/settings.rst | 21 +++++++++++++++++++ graphene_django/settings.py | 4 ++++ .../static/graphene_django/graphiql.js | 19 ++++++++++------- .../templates/graphene/graphiql.html | 1 + graphene_django/views.py | 2 ++ 5 files changed, 39 insertions(+), 8 deletions(-) diff --git a/docs/settings.rst b/docs/settings.rst index c2f8600..1e82e70 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -186,3 +186,24 @@ Default: ``None`` GRAPHENE = { 'SUBSCRIPTION_PATH': "/ws/graphql" } + + +``GRAPHIQL_HEADER_EDITOR_ENABLED`` +--------------------- + +GraphiQL starting from version 1.0.0 allows setting custom headers in similar fashion to query variables. + +Set to ``False`` if you want to disable GraphiQL headers editor tab for some reason. + +This setting is passed to ``headerEditorEnabled`` GraphiQL options, for details refer to GraphiQLDocs_. + +.. _GraphiQLDocs: https://github.com/graphql/graphiql/tree/main/packages/graphiql#options + + +Default: ``True`` + +.. code:: python + + GRAPHENE = { + 'GRAPHIQL_HEADER_EDITOR_ENABLED': True, + } diff --git a/graphene_django/settings.py b/graphene_django/settings.py index 52cca89..71b791c 100644 --- a/graphene_django/settings.py +++ b/graphene_django/settings.py @@ -41,6 +41,10 @@ DEFAULTS = { "DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME": None, # Use a separate path for handling subscriptions. "SUBSCRIPTION_PATH": None, + # By default GraphiQL headers editor tab is enabled, set to False to hide it + # This sets headerEditorEnabled GraphiQL option, for details go to + # https://github.com/graphql/graphiql/tree/main/packages/graphiql#options + "GRAPHIQL_HEADER_EDITOR_ENABLED": True, } if settings.DEBUG: diff --git a/graphene_django/static/graphene_django/graphiql.js b/graphene_django/static/graphene_django/graphiql.js index 45f8ad7..8c3b5ce 100644 --- a/graphene_django/static/graphene_django/graphiql.js +++ b/graphene_django/static/graphene_django/graphiql.js @@ -61,13 +61,15 @@ var fetchURL = locationQuery(otherParams); // Defines a GraphQL fetcher using the fetch API. - function httpClient(graphQLParams) { - var headers = { - Accept: "application/json", - "Content-Type": "application/json", - }; + function httpClient(graphQLParams, opts) { + if (typeof opts === 'undefined') { + opts = {}; + } + var headers = opts.headers || {}; + headers['Accept'] = headers['Accept'] || 'application/json'; + headers['Content-Type'] = headers['Content-Type'] || 'application/json'; if (csrftoken) { - headers["X-CSRFToken"] = csrftoken; + headers['X-CSRFToken'] = csrftoken } return fetch(fetchURL, { method: "post", @@ -108,7 +110,7 @@ var activeSubscription = null; // Define a GraphQL fetcher that can intelligently route queries based on the operation type. - function graphQLFetcher(graphQLParams) { + function graphQLFetcher(graphQLParams, opts) { var operationType = getOperationType(graphQLParams); // If we're about to execute a new operation, and we have an active subscription, @@ -126,7 +128,7 @@ }, }; } else { - return httpClient(graphQLParams); + return httpClient(graphQLParams, opts); } } @@ -173,6 +175,7 @@ onEditQuery: onEditQuery, onEditVariables: onEditVariables, onEditOperationName: onEditOperationName, + headerEditorEnabled: GRAPHENE_SETTINGS.graphiqlHeaderEditorEnabled, query: parameters.query, }; if (parameters.variables) { diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html index abc4b52..cec4893 100644 --- a/graphene_django/templates/graphene/graphiql.html +++ b/graphene_django/templates/graphene/graphiql.html @@ -45,6 +45,7 @@ add "&raw" to the end of the URL within a browser. {% if subscription_path %} subscriptionPath: "{{subscription_path}}", {% endif %} + graphiqlHeaderEditorEnabled: {{ graphiql_header_editor_enabled|yesno:"true,false" }}, }; diff --git a/graphene_django/views.py b/graphene_django/views.py index 59084e8..5ee0297 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -167,6 +167,8 @@ class GraphQLView(View): subscriptions_transport_ws_sri=self.subscriptions_transport_ws_sri, # The SUBSCRIPTION_PATH setting. subscription_path=self.subscription_path, + # GraphiQL headers tab, + graphiql_header_editor_enabled=graphene_settings.GRAPHIQL_HEADER_EDITOR_ENABLED, ) if self.batch: From 11dbde3beaa9882277082ec108b993c36be62f4e Mon Sep 17 00:00:00 2001 From: Thomas Leonard <64223923+tcleonard@users.noreply.github.com> Date: Fri, 7 Aug 2020 10:15:35 +0100 Subject: [PATCH 59/79] Fix Connection/Edge naming and add unit test (#1012) Co-authored-by: Thomas Leonard --- graphene_django/tests/test_types.py | 26 ++++++++++++++++++++++++++ graphene_django/types.py | 2 +- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 4d14749..fb95820 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -9,6 +9,7 @@ from graphene import Connection, Field, Interface, ObjectType, Schema, String from graphene.relay import Node from .. import registry +from ..filter import DjangoFilterConnectionField from ..types import DjangoObjectType, DjangoObjectTypeOptions from .models import Article as ArticleModel from .models import Reporter as ReporterModel @@ -580,3 +581,28 @@ class TestDjangoObjectType: } """ ) + + +@with_local_registry +def test_django_objecttype_name_connection_propagation(): + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + name = "CustomReporterName" + filter_fields = ["email"] + interfaces = (Node,) + + class Query(ObjectType): + reporter = Node.Field(Reporter) + reporters = DjangoFilterConnectionField(Reporter) + + assert Reporter._meta.name == "CustomReporterName" + schema = str(Schema(query=Query)) + + assert "type CustomReporterName implements Node {" in schema + assert "type CustomReporterNameConnection {" in schema + assert "type CustomReporterNameEdge {" in schema + + assert "type Reporter implements Node {" not in schema + assert "type ReporterConnection {" not in schema + assert "type ReporterEdge {" not in schema diff --git a/graphene_django/types.py b/graphene_django/types.py index b31fd0f..e38ae1f 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -239,7 +239,7 @@ class DjangoObjectType(ObjectType): connection_class = Connection connection = connection_class.create_type( - "{}Connection".format(cls.__name__), node=cls + "{}Connection".format(options.get("name") or cls.__name__), node=cls ) if connection is not None: From 67a0492c124587a98435621565c00b3f9e9053f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nikolai=20R=C3=B8ed=20Kristiansen?= Date: Fri, 7 Aug 2020 11:22:15 +0200 Subject: [PATCH 60/79] Add converter for django 3.1 JSONField (#1017) --- .github/workflows/tests.yml | 2 +- graphene_django/compat.py | 10 ++++++++-- graphene_django/converter.py | 5 +++-- graphene_django/tests/test_converter.py | 16 ++++++++++++++-- tox.ini | 4 +++- 5 files changed, 29 insertions(+), 8 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 37453f0..b9e57b5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: max-parallel: 4 matrix: - django: ["1.11", "2.2", "3.0"] + django: ["1.11", "2.2", "3.0", "3.1"] python-version: ["3.6", "3.7", "3.8"] include: - django: "1.11" diff --git a/graphene_django/compat.py b/graphene_django/compat.py index 59fab30..6e5e769 100644 --- a/graphene_django/compat.py +++ b/graphene_django/compat.py @@ -8,8 +8,14 @@ try: from django.contrib.postgres.fields import ( ArrayField, HStoreField, - JSONField, + JSONField as PGJSONField, RangeField, ) except ImportError: - ArrayField, HStoreField, JSONField, RangeField = (MissingType,) * 4 + ArrayField, HStoreField, PGJSONField, RangeField = (MissingType,) * 4 + +try: + # JSONField is only available from Django 3.1 + from django.contrib.fields import JSONField +except ImportError: + JSONField = MissingType diff --git a/graphene_django/converter.py b/graphene_django/converter.py index ca524ff..0de6964 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -24,7 +24,7 @@ from graphene.utils.str_converters import to_camel_case from graphql import assert_valid_name from .settings import graphene_settings -from .compat import ArrayField, HStoreField, JSONField, RangeField +from .compat import ArrayField, HStoreField, JSONField, PGJSONField, RangeField from .fields import DjangoListField, DjangoConnectionField from .utils import import_single_dispatch from .utils.str_converters import to_const @@ -267,8 +267,9 @@ def convert_postgres_array_to_list(field, registry=None): @convert_django_field.register(HStoreField) +@convert_django_field.register(PGJSONField) @convert_django_field.register(JSONField) -def convert_postgres_field_to_string(field, registry=None): +def convert_pg_and_json_field_to_string(field, registry=None): return JSONString(description=field.help_text, required=not field.null) diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index f6e3606..7d8e669 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -11,7 +11,14 @@ from graphene.relay import ConnectionField, Node from graphene.types.datetime import Date, DateTime, Time from graphene.types.json import JSONString -from ..compat import ArrayField, HStoreField, JSONField, MissingType, RangeField +from ..compat import ( + ArrayField, + HStoreField, + JSONField, + PGJSONField, + MissingType, + RangeField, +) from ..converter import ( convert_django_field, convert_django_field_with_choices, @@ -348,8 +355,13 @@ def test_should_postgres_hstore_convert_string(): assert_conversion(HStoreField, JSONString) -@pytest.mark.skipif(JSONField is MissingType, reason="JSONField should exist") +@pytest.mark.skipif(PGJSONField is MissingType, reason="PGJSONField should exist") def test_should_postgres_json_convert_string(): + assert_conversion(PGJSONField, JSONString) + + +@pytest.mark.skipif(JSONField is MissingType, reason="JSONField should exist") +def test_should_json_convert_string(): assert_conversion(JSONField, JSONString) diff --git a/tox.ini b/tox.ini index 6744c5b..9086a55 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = py{27,35,36,37,38}-django{111,20,21,22,master}, - py{36,37,38}-django30, + py{36,37,38}-django{30,31}, black,flake8 [gh-actions] @@ -18,6 +18,7 @@ DJANGO = 2.1: django21 2.2: django22 3.0: django30 + 3.1: django31 master: djangomaster [testenv] @@ -33,6 +34,7 @@ deps = django21: Django>=2.1,<2.2 django22: Django>=2.2,<3.0 django30: Django>=3.0a1,<3.1 + django31: Django>=3.1,<3.2 djangomaster: https://github.com/django/django/archive/master.zip commands = {posargs:py.test --cov=graphene_django graphene_django examples} From bd553be10e6200c6558cc7dd91d7bc3743325d6e Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Wed, 12 Aug 2020 07:03:23 +0100 Subject: [PATCH 61/79] Fix JSONField import (#1021) --- graphene_django/compat.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/compat.py b/graphene_django/compat.py index 6e5e769..8a2b933 100644 --- a/graphene_django/compat.py +++ b/graphene_django/compat.py @@ -16,6 +16,6 @@ except ImportError: try: # JSONField is only available from Django 3.1 - from django.contrib.fields import JSONField + from django.db.models import JSONField except ImportError: JSONField = MissingType From 5b1451132d80869ac46eb42faac2836bda6c658a Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Wed, 12 Aug 2020 07:10:01 +0100 Subject: [PATCH 62/79] v2.13.0 --- graphene_django/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index f94b5be..03ccbeb 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,7 +1,7 @@ from .fields import DjangoConnectionField, DjangoListField from .types import DjangoObjectType -__version__ = "2.12.1" +__version__ = "2.13.0" __all__ = [ "__version__", From ac1f9ac360138c7773774a61dd39da0e1d510425 Mon Sep 17 00:00:00 2001 From: Varun Dey Date: Mon, 24 Aug 2020 16:19:53 +0000 Subject: [PATCH 63/79] Fix grammar (#1027) --- docs/installation.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/installation.rst b/docs/installation.rst index 35272b0..3628163 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -85,7 +85,7 @@ To learn how to extend the schema object for your project, read the basic tutori CSRF exempt ----------- -If have enabled `CSRF protection `_ in your Django app +If you have enabled `CSRF protection `_ in your Django app you will find that it prevents your API clients from POSTing to the ``graphql`` endpoint. You can either update your API client to pass the CSRF token with each request (the Django docs have a guide on how to do that: https://docs.djangoproject.com/en/3.0/ref/csrf/#ajax) or you can exempt your Graphql endpoint from CSRF protection by wrapping the ``GraphQLView`` with the ``csrf_exempt`` decorator: From 6ce208db95bab5c4323715302408cc2d2e624d82 Mon Sep 17 00:00:00 2001 From: Varun Dey Date: Wed, 26 Aug 2020 14:57:53 +0000 Subject: [PATCH 64/79] Fix missing colon in function definition (#1030) --- docs/testing.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/testing.rst b/docs/testing.rst index 23acef2..6862006 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -94,7 +94,7 @@ To use pytest define a simple fixture using the query helper below from graphene_django.utils.testing import graphql_query @pytest.fixture - def client_query(client) + def client_query(client): def func(*args, **kwargs): return graphql_query(*args, **kwargs, client=client) @@ -115,4 +115,4 @@ To use pytest define a simple fixture using the query helper below ) content = json.loads(response.content) - assert 'errors' not in content \ No newline at end of file + assert 'errors' not in content From 26960359a2d194e6f5f5923c8b85c49b383a8e01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=9Clgen=20Sar=C4=B1kavak?= Date: Wed, 26 Aug 2020 17:58:48 +0300 Subject: [PATCH 65/79] Add msg params to testing class (#1032) --- graphene_django/utils/testing.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/graphene_django/utils/testing.py b/graphene_django/utils/testing.py index 6b2d3e8..b1aa29b 100644 --- a/graphene_django/utils/testing.py +++ b/graphene_django/utils/testing.py @@ -103,7 +103,7 @@ class GraphQLTestCase(TestCase): graphql_url=self.GRAPHQL_URL, ) - def assertResponseNoErrors(self, resp): + def assertResponseNoErrors(self, resp, msg=None): """ Assert that the call went through correctly. 200 means the syntax is ok, if there are no `errors`, the call was fine. @@ -111,12 +111,12 @@ class GraphQLTestCase(TestCase): """ self.assertEqual(resp.status_code, 200) content = json.loads(resp.content) - self.assertNotIn("errors", list(content.keys())) + self.assertNotIn("errors", list(content.keys()), msg) - def assertResponseHasErrors(self, resp): + def assertResponseHasErrors(self, resp, msg=None): """ Assert that the call was failing. Take care: Even with errors, GraphQL returns status 200! :resp HttpResponse: Response """ content = json.loads(resp.content) - self.assertIn("errors", list(content.keys())) + self.assertIn("errors", list(content.keys()), msg) From 88eefb0e073b55563fe7a8d526d7ab21bc99777c Mon Sep 17 00:00:00 2001 From: Semyon Pupkov Date: Wed, 26 Aug 2020 19:59:43 +0500 Subject: [PATCH 66/79] Fix testing doc (#1024) --- docs/testing.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/testing.rst b/docs/testing.rst index 6862006..72907b4 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -102,7 +102,7 @@ To use pytest define a simple fixture using the query helper below # Test you query using the client_query fixture def test_some_query(client_query): - response = graphql_query( + response = client_query( ''' query { myModel { From f5d94fda1f4091d64783db3e6e623522d676d58a Mon Sep 17 00:00:00 2001 From: DJ Kim Date: Wed, 26 Aug 2020 07:01:44 -0800 Subject: [PATCH 67/79] Update testing.rst (#1026) Co-authored-by: DJ Kim --- docs/testing.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/testing.rst b/docs/testing.rst index 72907b4..c24f7c4 100644 --- a/docs/testing.rst +++ b/docs/testing.rst @@ -90,6 +90,7 @@ To use pytest define a simple fixture using the query helper below .. code:: python # Create a fixture using the graphql_query helper and `client` fixture from `pytest-django`. + import json import pytest from graphene_django.utils.testing import graphql_query From 19e3eddddbbc236c4dc3b0f7732431162b880287 Mon Sep 17 00:00:00 2001 From: Josh Warwick Date: Wed, 26 Aug 2020 16:03:08 +0100 Subject: [PATCH 68/79] Allow passing of meta object in SerializerMutation options (#1028) --- graphene_django/rest_framework/mutation.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py index 592f8b3..d1dbb38 100644 --- a/graphene_django/rest_framework/mutation.py +++ b/graphene_django/rest_framework/mutation.py @@ -66,6 +66,7 @@ class SerializerMutation(ClientIDMutation): only_fields=(), exclude_fields=(), convert_choices_to_enum=True, + _meta=None, **options ): @@ -99,7 +100,8 @@ class SerializerMutation(ClientIDMutation): convert_choices_to_enum=convert_choices_to_enum, ) - _meta = SerializerMutationOptions(cls) + if not _meta: + _meta = SerializerMutationOptions(cls) _meta.lookup_field = lookup_field _meta.model_operations = model_operations _meta.serializer_class = serializer_class From 86a66db1f69e1772ab43f31c9a1fb25e1d7f2d4b Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Wed, 26 Aug 2020 16:20:41 +0100 Subject: [PATCH 69/79] Disable stalebot --- .github/stale.yml | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/.github/stale.yml b/.github/stale.yml index d066ca6..35c5ab1 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,21 +1,21 @@ -# Number of days of inactivity before an issue becomes stale -daysUntilStale: 120 -# Number of days of inactivity before a stale issue is closed -daysUntilClose: 30 -# Issues with these labels will never be considered stale -exemptLabels: - - pinned - - security - - 🐛bug - - 📖 documentation - - help wanted - - ✨enhancement -# Label to use when marking an issue as stale -staleLabel: wontfix -# Comment to post when marking an issue as stale. Set to `false` to disable -markComment: > - This issue has been automatically marked as stale because it has not had - recent activity. It will be closed if no further activity occurs. Thank you - for your contributions. -# Comment to post when closing a stale issue. Set to `false` to disable -closeComment: false +## Number of days of inactivity before an issue becomes stale +# daysUntilStale: 120 +## Number of days of inactivity before a stale issue is closed +# daysUntilClose: 30 +## Issues with these labels will never be considered stale +# exemptLabels: + # - pinned + # - security + # - 🐛bug + # - 📖 documentation + # - help wanted + # - ✨enhancement +## Label to use when marking an issue as stale +# staleLabel: wontfix +## Comment to post when marking an issue as stale. Set to `false` to disable +# markComment: > + # This issue has been automatically marked as stale because it has not had + # recent activity. It will be closed if no further activity occurs. Thank you + # for your contributions. +## Comment to post when closing a stale issue. Set to `false` to disable +# closeComment: false From 2e806384f60505a29745752bf9c477c71668f0fa Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Thu, 27 Aug 2020 12:48:29 +0100 Subject: [PATCH 70/79] Update stale.yml --- .github/stale.yml | 35 ++++++++++++++++++----------------- 1 file changed, 18 insertions(+), 17 deletions(-) diff --git a/.github/stale.yml b/.github/stale.yml index 35c5ab1..b9356c8 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,21 +1,22 @@ -## Number of days of inactivity before an issue becomes stale -# daysUntilStale: 120 -## Number of days of inactivity before a stale issue is closed -# daysUntilClose: 30 -## Issues with these labels will never be considered stale -# exemptLabels: - # - pinned - # - security - # - 🐛bug - # - 📖 documentation - # - help wanted - # - ✨enhancement -## Label to use when marking an issue as stale -# staleLabel: wontfix -## Comment to post when marking an issue as stale. Set to `false` to disable +# Number of days of inactivity before an issue becomes stale +daysUntilStale: false +# Number of days of inactivity before a stale issue is closed +daysUntilClose: false +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - security + - 🐛bug + - 📖 documentation + - help wanted + - ✨enhancement +# Label to use when marking an issue as stale +staleLabel: wontfix +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: false # markComment: > # This issue has been automatically marked as stale because it has not had # recent activity. It will be closed if no further activity occurs. Thank you # for your contributions. -## Comment to post when closing a stale issue. Set to `false` to disable -# closeComment: false +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: false From ee3d4f521f0adc58fa41fc28255dc35f24efda4a Mon Sep 17 00:00:00 2001 From: Andreas Hasenkopf Date: Mon, 19 Oct 2020 18:23:41 +0200 Subject: [PATCH 71/79] Include tests and examples in source package, but don't install them (#1034) ..., but don't install them. Also applied changes as suggested by `black`. --- MANIFEST.in | 3 +++ django_test_settings.py | 35 ---------------------------- examples/__init__.py | 0 examples/cookbook-plain/__init__.py | 0 examples/cookbook/__init__.py | 0 examples/django_test_settings.py | 30 ++++++++++++++++++++++++ graphene_django/tests/test_fields.py | 6 ++--- pytest.ini | 2 +- setup.py | 2 +- tox.ini | 2 +- 10 files changed, 39 insertions(+), 41 deletions(-) delete mode 100644 django_test_settings.py create mode 100644 examples/__init__.py create mode 100644 examples/cookbook-plain/__init__.py create mode 100644 examples/cookbook/__init__.py create mode 100644 examples/django_test_settings.py diff --git a/MANIFEST.in b/MANIFEST.in index 4677330..045af08 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,3 +1,6 @@ include README.md LICENSE recursive-include graphene_django/templates * recursive-include graphene_django/static * + +include examples/cookbook/cookbook/ingredients/fixtures/ingredients.json +include examples/cookbook-plain/cookbook/ingredients/fixtures/ingredients.json \ No newline at end of file diff --git a/django_test_settings.py b/django_test_settings.py deleted file mode 100644 index 9279a73..0000000 --- a/django_test_settings.py +++ /dev/null @@ -1,35 +0,0 @@ -import sys -import os - -ROOT_PATH = os.path.dirname(os.path.abspath(__file__)) -sys.path.insert(0, ROOT_PATH + '/examples/') - -SECRET_KEY = 1 - -INSTALLED_APPS = [ - 'graphene_django', - 'graphene_django.rest_framework', - 'graphene_django.tests', - 'starwars', -] - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': 'django_test.sqlite', - } -} - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - }, -] - -GRAPHENE = { - 'SCHEMA': 'graphene_django.tests.schema_view.schema' -} - -ROOT_URLCONF = 'graphene_django.tests.urls' diff --git a/examples/__init__.py b/examples/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/cookbook-plain/__init__.py b/examples/cookbook-plain/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/cookbook/__init__.py b/examples/cookbook/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/examples/django_test_settings.py b/examples/django_test_settings.py new file mode 100644 index 0000000..7b98861 --- /dev/null +++ b/examples/django_test_settings.py @@ -0,0 +1,30 @@ +import sys +import os + +ROOT_PATH = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, ROOT_PATH + "/examples/") + +SECRET_KEY = 1 + +INSTALLED_APPS = [ + "graphene_django", + "graphene_django.rest_framework", + "graphene_django.tests", + "examples.starwars", +] + +DATABASES = { + "default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "django_test.sqlite"} +} + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + } +] + +GRAPHENE = {"SCHEMA": "graphene_django.tests.schema_view.schema"} + +ROOT_URLCONF = "graphene_django.tests.urls" diff --git a/graphene_django/tests/test_fields.py b/graphene_django/tests/test_fields.py index cd5bd1b..57f913e 100644 --- a/graphene_django/tests/test_fields.py +++ b/graphene_django/tests/test_fields.py @@ -267,7 +267,7 @@ class TestDjangoListField: result = schema.execute(query) assert not result.errors - assert result.data == {"reporters": [{"firstName": "Tara"},]} + assert result.data == {"reporters": [{"firstName": "Tara"}]} def test_resolve_list(self): """Resolving a plain list should work (and not call get_queryset)""" @@ -314,7 +314,7 @@ class TestDjangoListField: result = schema.execute(query) assert not result.errors - assert result.data == {"reporters": [{"firstName": "Debra"},]} + assert result.data == {"reporters": [{"firstName": "Debra"}]} def test_get_queryset_foreign_key(self): class Article(DjangoObjectType): @@ -371,7 +371,7 @@ class TestDjangoListField: assert not result.errors assert result.data == { "reporters": [ - {"firstName": "Tara", "articles": [{"headline": "Amazing news"},],}, + {"firstName": "Tara", "articles": [{"headline": "Amazing news"}]}, {"firstName": "Debra", "articles": []}, ] } diff --git a/pytest.ini b/pytest.ini index 4e47ff4..94360bd 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,2 +1,2 @@ [pytest] -DJANGO_SETTINGS_MODULE = django_test_settings +DJANGO_SETTINGS_MODULE = examples.django_test_settings diff --git a/setup.py b/setup.py index 8a070a9..e33cfca 100644 --- a/setup.py +++ b/setup.py @@ -58,7 +58,7 @@ setup( "Framework :: Django :: 3.0", ], keywords="api graphql protocol rest relay graphene", - packages=find_packages(exclude=["tests"]), + packages=find_packages(exclude=["tests", "examples", "examples.*"]), install_requires=[ "six>=1.10.0", "graphene>=2.1.7,<3", diff --git a/tox.ini b/tox.ini index 9086a55..bd2f727 100644 --- a/tox.ini +++ b/tox.ini @@ -25,7 +25,7 @@ DJANGO = passenv = * usedevelop = True setenv = - DJANGO_SETTINGS_MODULE=django_test_settings + DJANGO_SETTINGS_MODULE=examples.django_test_settings deps = -e.[test] psycopg2-binary From 65f41c1a177d34282ffa9e32f51eac4312fb6184 Mon Sep 17 00:00:00 2001 From: CBuiVNG <46447828+CBuiVNG@users.noreply.github.com> Date: Mon, 19 Oct 2020 18:25:35 +0200 Subject: [PATCH 72/79] BUGFIX: don't filter out lookup_field as input (required for update) (#1029) --- graphene_django/rest_framework/mutation.py | 7 ++++++- graphene_django/rest_framework/tests/test_mutation.py | 5 ++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py index d1dbb38..000b21e 100644 --- a/graphene_django/rest_framework/mutation.py +++ b/graphene_django/rest_framework/mutation.py @@ -26,6 +26,7 @@ def fields_for_serializer( exclude_fields, is_input=False, convert_choices_to_enum=True, + lookup_field=None, ): fields = OrderedDict() for name, field in serializer.fields.items(): @@ -35,7 +36,9 @@ def fields_for_serializer( name in exclude_fields, field.write_only and not is_input, # don't show write_only fields in Query - field.read_only and is_input, # don't show read_only fields in Input + field.read_only + and is_input + and lookup_field != name, # don't show read_only fields in Input ] ) @@ -91,6 +94,7 @@ class SerializerMutation(ClientIDMutation): exclude_fields, is_input=True, convert_choices_to_enum=convert_choices_to_enum, + lookup_field=lookup_field, ) output_fields = fields_for_serializer( serializer, @@ -98,6 +102,7 @@ class SerializerMutation(ClientIDMutation): exclude_fields, is_input=False, convert_choices_to_enum=convert_choices_to_enum, + lookup_field=lookup_field, ) if not _meta: diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py index 1599fea..ffbc4b5 100644 --- a/graphene_django/rest_framework/tests/test_mutation.py +++ b/graphene_django/rest_framework/tests/test_mutation.py @@ -143,17 +143,20 @@ def test_write_only_field_using_extra_kwargs(): def test_read_only_fields(): class ReadOnlyFieldModelSerializer(serializers.ModelSerializer): + id = serializers.CharField(read_only=True) cool_name = serializers.CharField(read_only=True) class Meta: model = MyFakeModelWithPassword - fields = ["cool_name", "password"] + lookup_field = "id" + fields = ["id", "cool_name", "password"] class MyMutation(SerializerMutation): class Meta: serializer_class = ReadOnlyFieldModelSerializer assert "password" in MyMutation.Input._meta.fields + assert "id" in MyMutation.Input._meta.fields assert ( "cool_name" not in MyMutation.Input._meta.fields ), "'cool_name' is read_only field and shouldn't be on arguments" From 8928ec2dbecb5d2c0bfc495fc0ff9eb9ea7eb8b2 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Mon, 19 Oct 2020 20:20:30 +0100 Subject: [PATCH 73/79] Restrict DjangoRestFramework version (#1047) --- tox.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/tox.ini b/tox.ini index bd2f727..d2d3065 100644 --- a/tox.ini +++ b/tox.ini @@ -30,6 +30,7 @@ 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 From 8408c51bf9423402d308c2a7f9e8f574979cd5a3 Mon Sep 17 00:00:00 2001 From: Roberto Barreda Date: Mon, 26 Oct 2020 10:09:49 +0100 Subject: [PATCH 74/79] fix `variables` key in body (#1050) --- graphene_django/utils/testing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphene_django/utils/testing.py b/graphene_django/utils/testing.py index b1aa29b..7c9b152 100644 --- a/graphene_django/utils/testing.py +++ b/graphene_django/utils/testing.py @@ -45,7 +45,7 @@ def graphql_query( if variables: body["variables"] = variables if input_data: - if variables in body: + if "variables" in body: body["variables"]["input"] = input_data else: body["variables"] = {"input": input_data} From 2140be5e6a2d3a789906f34bf96c9663dfc075be Mon Sep 17 00:00:00 2001 From: Thomas Leonard <64223923+tcleonard@users.noreply.github.com> Date: Mon, 26 Oct 2020 17:09:21 +0100 Subject: [PATCH 75/79] Add offset pagination (#1013) * Add offset filtering * Formatting Co-authored-by: Thomas Leonard --- graphene_django/fields.py | 21 ++- graphene_django/filter/tests/test_fields.py | 6 +- graphene_django/tests/test_query.py | 141 ++++++++++++++++++++ graphene_django/tests/test_types.py | 2 +- 4 files changed, 165 insertions(+), 5 deletions(-) diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 67559aa..78efceb 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -4,11 +4,13 @@ import six from django.db.models.query import QuerySet from graphql_relay.connection.arrayconnection import ( connection_from_list_slice, + cursor_to_offset, get_offset_with_default, + offset_to_cursor, ) from promise import Promise -from graphene import NonNull +from graphene import Int, NonNull from graphene.relay import ConnectionField, PageInfo from graphene.types import Field, List @@ -81,6 +83,7 @@ class DjangoConnectionField(ConnectionField): "enforce_first_or_last", graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST, ) + kwargs.setdefault("offset", Int()) super(DjangoConnectionField, self).__init__(*args, **kwargs) @property @@ -131,6 +134,15 @@ class DjangoConnectionField(ConnectionField): @classmethod def resolve_connection(cls, connection, args, iterable, max_limit=None): + # Remove the offset parameter and convert it to an after cursor. + offset = args.pop("offset", None) + after = args.get("after") + if offset: + if after: + offset += cursor_to_offset(after) + 1 + # input offset starts at 1 while the graphene offset starts at 0 + args["after"] = offset_to_cursor(offset - 1) + iterable = maybe_queryset(iterable) if isinstance(iterable, QuerySet): @@ -181,6 +193,8 @@ class DjangoConnectionField(ConnectionField): ): first = args.get("first") last = args.get("last") + offset = args.get("offset") + before = args.get("before") if enforce_first_or_last: assert first or last, ( @@ -200,6 +214,11 @@ class DjangoConnectionField(ConnectionField): ).format(last, info.field_name, max_limit) args["last"] = min(last, max_limit) + if offset is not None: + assert before is None, ( + "You can't provide a `before` value at the same time as an `offset` value to properly paginate the `{}` connection." + ).format(info.field_name) + # eventually leads to DjangoObjectType's get_queryset (accepts queryset) # or a resolve_foo (does not accept queryset) iterable = resolver(root, info, **args) diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index b8ae6fe..88749aa 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -59,7 +59,7 @@ def get_args(field): def assert_arguments(field, *arguments): - ignore = ("after", "before", "first", "last", "order_by") + ignore = ("offset", "after", "before", "first", "last", "order_by") args = get_args(field) actual = [name for name in args if name not in ignore and not name.startswith("_")] assert set(arguments) == set( @@ -945,7 +945,7 @@ def test_integer_field_filter_type(): } type Query { - pets(before: String, after: String, first: Int, last: Int, age: Int): PetTypeConnection + pets(offset: Int, before: String, after: String, first: Int, last: Int, age: Int): PetTypeConnection } """ ) @@ -997,7 +997,7 @@ def test_other_filter_types(): } type Query { - pets(before: String, after: String, first: Int, last: Int, age: Int, age_Isnull: Boolean, age_Lt: Int): PetTypeConnection + pets(offset: Int, before: String, after: String, first: Int, last: Int, age: Int, age_Isnull: Boolean, age_Lt: Int): PetTypeConnection } """ ) diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 3881ed8..6add0b8 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1314,3 +1314,144 @@ def test_should_preserve_annotations(): } } assert result.data == expected, str(result.data) + + +def test_connection_should_enable_offset_filtering(): + Reporter.objects.create(first_name="John", last_name="Doe") + Reporter.objects.create(first_name="Some", last_name="Guy") + + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + + class Query(graphene.ObjectType): + all_reporters = DjangoConnectionField(ReporterType) + + schema = graphene.Schema(query=Query) + query = """ + query { + allReporters(first: 1, offset: 1) { + edges { + node { + firstName + lastName + } + } + } + } + """ + + result = schema.execute(query) + assert not result.errors + expected = { + "allReporters": {"edges": [{"node": {"firstName": "Some", "lastName": "Guy"}},]} + } + assert result.data == expected + + +def test_connection_should_enable_offset_filtering_higher_than_max_limit( + graphene_settings, +): + graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 2 + Reporter.objects.create(first_name="John", last_name="Doe") + Reporter.objects.create(first_name="Some", last_name="Guy") + Reporter.objects.create(first_name="Jane", last_name="Roe") + Reporter.objects.create(first_name="Some", last_name="Lady") + + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + + class Query(graphene.ObjectType): + all_reporters = DjangoConnectionField(ReporterType) + + schema = graphene.Schema(query=Query) + query = """ + query { + allReporters(first: 1, offset: 3) { + edges { + node { + firstName + lastName + } + } + } + } + """ + + result = schema.execute(query) + assert not result.errors + expected = { + "allReporters": { + "edges": [{"node": {"firstName": "Some", "lastName": "Lady"}},] + } + } + assert result.data == expected + + +def test_connection_should_forbid_offset_filtering_with_before(): + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + + class Query(graphene.ObjectType): + all_reporters = DjangoConnectionField(ReporterType) + + schema = graphene.Schema(query=Query) + query = """ + query ReporterPromiseConnectionQuery ($before: String) { + allReporters(first: 1, before: $before, offset: 1) { + edges { + node { + firstName + lastName + } + } + } + } + """ + before = base64.b64encode(b"arrayconnection:2").decode() + result = schema.execute(query, variable_values=dict(before=before)) + expected_error = "You can't provide a `before` value at the same time as an `offset` value to properly paginate the `allReporters` connection." + assert len(result.errors) == 1 + assert result.errors[0].message == expected_error + + +def test_connection_should_allow_offset_filtering_with_after(): + Reporter.objects.create(first_name="John", last_name="Doe") + Reporter.objects.create(first_name="Some", last_name="Guy") + Reporter.objects.create(first_name="Jane", last_name="Roe") + Reporter.objects.create(first_name="Some", last_name="Lady") + + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + + class Query(graphene.ObjectType): + all_reporters = DjangoConnectionField(ReporterType) + + schema = graphene.Schema(query=Query) + query = """ + query ReporterPromiseConnectionQuery ($after: String) { + allReporters(first: 1, after: $after, offset: 1) { + edges { + node { + firstName + lastName + } + } + } + } + """ + + after = base64.b64encode(b"arrayconnection:0").decode() + result = schema.execute(query, variable_values=dict(after=after)) + assert not result.errors + expected = { + "allReporters": {"edges": [{"node": {"firstName": "Jane", "lastName": "Roe"}},]} + } + assert result.data == expected diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index fb95820..ce9a29b 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -172,7 +172,7 @@ type Reporter { pets: [Reporter!]! aChoice: ReporterAChoice reporterType: ReporterReporterType - articles(before: String, after: String, first: Int, last: Int): ArticleConnection! + articles(offset: Int, before: String, after: String, first: Int, last: Int): ArticleConnection! } enum ReporterAChoice { From 8571bc465a1e474b2025d1f1b5521f3c065eca1f Mon Sep 17 00:00:00 2001 From: Semyon Pupkov Date: Sun, 1 Nov 2020 23:16:15 +0500 Subject: [PATCH 76/79] Improve ordering doc example (#1053) --- docs/filtering.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/filtering.rst b/docs/filtering.rst index dbbab9d..e366fe2 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -201,7 +201,7 @@ Extend the tuple of fields if you want to order by more than one field. order_by = OrderingFilter( fields=( - ('created_at', 'created_at'), + ('name', 'created_at'), ) ) From f5549113979d9c8a8e639d8c5d6a4a8c118f338d Mon Sep 17 00:00:00 2001 From: Nishchit Date: Sat, 7 Nov 2020 05:34:45 +0530 Subject: [PATCH 77/79] Section added `GraphQL testing clients` (#919) --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 8605065..d813508 100644 --- a/README.md +++ b/README.md @@ -110,6 +110,11 @@ To learn more check out the following [examples](examples/): * **Relay Schema**: [Starwars Relay example](examples/starwars) +## GraphQL testing clients + - [Firecamp](https://firecamp.io/graphql) + - [GraphiQL](https://github.com/graphql/graphiql) + + ## Contributing See [CONTRIBUTING.md](CONTRIBUTING.md) From 0888c748fdc02c5446e15c538b1b1294d2fbb2ea Mon Sep 17 00:00:00 2001 From: Semyon Pupkov Date: Sun, 8 Nov 2020 10:44:37 +0500 Subject: [PATCH 78/79] Change build badge from travis to github actions (#1058) --- README.md | 6 +++--- README.rst | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index d813508..73ce5bf 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,13 @@ A [Django](https://www.djangoproject.com/) integration for [Graphene](http://graphene-python.org/). -[![travis][travis-image]][travis-url] +[![build][build-image]][build-url] [![pypi][pypi-image]][pypi-url] [![Anaconda-Server Badge][conda-image]][conda-url] [![coveralls][coveralls-image]][coveralls-url] -[travis-image]: https://travis-ci.org/graphql-python/graphene-django.svg?branch=master&style=flat -[travis-url]: https://travis-ci.org/graphql-python/graphene-django +[build-image]: https://github.com/graphql-python/graphene-django/workflows/Tests/badge.svg +[build-url]: https://github.com/graphql-python/graphene-django/actions [pypi-image]: https://img.shields.io/pypi/v/graphene-django.svg?style=flat [pypi-url]: https://pypi.org/project/graphene-django/ [coveralls-image]: https://coveralls.io/repos/github/graphql-python/graphene-django/badge.svg?branch=master diff --git a/README.rst b/README.rst index 44feaee..dc7275c 100644 --- a/README.rst +++ b/README.rst @@ -114,8 +114,8 @@ Contributing See `CONTRIBUTING.md `__. .. |Graphene Logo| image:: http://graphene-python.org/favicon.png -.. |Build Status| image:: https://travis-ci.org/graphql-python/graphene-django.svg?branch=master - :target: https://travis-ci.org/graphql-python/graphene-django +.. |Build Status| image:: https://github.com/graphql-python/graphene-django/workflows/Tests/badge.svg + :target: https://github.com/graphql-python/graphene-django/actions .. |PyPI version| image:: https://badge.fury.io/py/graphene-django.svg :target: https://badge.fury.io/py/graphene-django .. |Coverage Status| image:: https://coveralls.io/repos/graphql-python/graphene-django/badge.svg?branch=master&service=github From eb7a0265d8360d539385f9ce152ec95d74cc5f1a Mon Sep 17 00:00:00 2001 From: Semyon Pupkov Date: Mon, 9 Nov 2020 22:06:53 +0500 Subject: [PATCH 79/79] Use explicit classmethod in simple mutation example (#1059) rel #1038 --- docs/mutations.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/mutations.rst b/docs/mutations.rst index aef32eb..9b6f179 100644 --- a/docs/mutations.rst +++ b/docs/mutations.rst @@ -36,7 +36,8 @@ Simple example # The class attributes define the response of the mutation question = graphene.Field(QuestionType) - def mutate(self, info, text, id): + @classmethod + def mutate(cls, root, info, text, id): question = Question.objects.get(pk=id) question.text = text question.save()