From 6e8dce95ae184cc3f8c3c9202ba92f8fd92c09d1 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Fri, 14 Jun 2019 12:33:37 +0100 Subject: [PATCH 01/28] Update doc setup (#673) * Expose doc commands in root makefile and add autobuild * Fix some errors * Alias some commands and add PHONY --- Makefile | 18 ++++++++++++++++++ docs/Makefile | 8 ++++++++ docs/_static/.gitkeep | 0 docs/authorization.rst | 3 ++- docs/requirements.txt | 3 ++- docs/settings.rst | 10 +++++----- 6 files changed, 35 insertions(+), 7 deletions(-) create mode 100644 docs/_static/.gitkeep diff --git a/Makefile b/Makefile index 70badcb..39a0f31 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,29 @@ +.PHONY: dev-setup ## Install development dependencies dev-setup: pip install -e ".[dev]" +.PHONY: install-dev +install-dev: dev-setup # Alias install-dev -> dev-setup + +.PHONY: tests tests: py.test graphene_django --cov=graphene_django -vv +.PHONY: test +test: tests # Alias test -> tests + +.PHONY: format format: black --exclude "/migrations/" graphene_django examples +.PHONY: lint lint: flake8 graphene_django examples + +.PHONY: docs ## Generate docs +docs: dev-setup + cd docs && make install && make html + +.PHONY: docs-live ## Generate docs with live reloading +docs-live: dev-setup + cd docs && make install && make livehtml diff --git a/docs/Makefile b/docs/Makefile index 7da67c3..4ae2962 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -48,12 +48,20 @@ help: clean: rm -rf $(BUILDDIR)/* +.PHONY: install ## to install all documentation related requirements +install: + pip install -r requirements.txt + .PHONY: html html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." +.PHONY: livehtml ## to build and serve live-reloading documentation +livehtml: + sphinx-autobuild -b html --watch ../graphene_django $(ALLSPHINXOPTS) $(BUILDDIR)/html + .PHONY: dirhtml dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/authorization.rst b/docs/authorization.rst index 3d0bb8a..2c38fa4 100644 --- a/docs/authorization.rst +++ b/docs/authorization.rst @@ -154,7 +154,8 @@ Adding Login Required To restrict users from accessing the GraphQL API page the standard Django LoginRequiredMixin_ can be used to create your own standard Django Class Based View, which includes the ``LoginRequiredMixin`` and subclasses the ``GraphQLView``.: .. code:: python - #views.py + + # views.py from django.contrib.auth.mixins import LoginRequiredMixin from graphene_django.views import GraphQLView diff --git a/docs/requirements.txt b/docs/requirements.txt index 220b7cf..7c89926 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ -sphinx +Sphinx==1.5.3 +sphinx-autobuild==0.7.1 # Docs template http://graphene-python.org/sphinx_graphene_theme.zip diff --git a/docs/settings.rst b/docs/settings.rst index 547e77f..4d37a99 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -30,7 +30,7 @@ Default: ``None`` ``SCHEMA_OUTPUT`` ----------- +----------------- The name of the file where the GraphQL schema output will go. @@ -44,7 +44,7 @@ Default: ``schema.json`` ``SCHEMA_INDENT`` ----------- +----------------- The indentation level of the schema output. @@ -58,7 +58,7 @@ Default: ``2`` ``MIDDLEWARE`` ----------- +-------------- A tuple of middleware that will be executed for each GraphQL query. @@ -76,7 +76,7 @@ Default: ``()`` ``RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST`` ----------- +------------------------------------------ Enforces relay queries to have the ``first`` or ``last`` argument. @@ -90,7 +90,7 @@ Default: ``False`` ``RELAY_CONNECTION_MAX_LIMIT`` ----------- +------------------------------ The maximum size of objects that can be requested through a relay connection. From 6169346776854c055f6349509e4e02d64b00863e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" Date: Mon, 17 Jun 2019 17:08:51 +0100 Subject: [PATCH 02/28] Bump django from 1.11.20 to 1.11.21 in /examples/cookbook (#670) Bumps [django](https://github.com/django/django) from 1.11.20 to 1.11.21. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/1.11.20...1.11.21) Signed-off-by: dependabot[bot] --- 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 fe0527a..9d13a82 100644 --- a/examples/cookbook/requirements.txt +++ b/examples/cookbook/requirements.txt @@ -1,5 +1,5 @@ graphene graphene-django graphql-core>=2.1rc1 -django==1.11.20 +django==1.11.21 django-filter>=2 From 894b1053a2bb40e7f52601f70d65e3ebd7c51fe5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" Date: Mon, 17 Jun 2019 18:48:15 +0100 Subject: [PATCH 03/28] Bump django from 2.1.6 to 2.1.9 in /examples/cookbook-plain (#669) Bumps [django](https://github.com/django/django) from 2.1.6 to 2.1.9. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.1.6...2.1.9) Signed-off-by: dependabot[bot] --- 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 2154fd8..ea1f4ba 100644 --- a/examples/cookbook-plain/requirements.txt +++ b/examples/cookbook-plain/requirements.txt @@ -1,4 +1,4 @@ graphene graphene-django graphql-core>=2.1rc1 -django==2.1.6 +django==2.1.9 From 612ba5a4eaea0336a5dffcba3dbe7909b9d94646 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Mon, 17 Jun 2019 18:48:29 +0100 Subject: [PATCH 04/28] Add `convert_choices_to_enum` option on DjangoObjectType Meta class (#674) * Add convert_choices_to_enum meta option * Add tests * Run black * Update documentation * Add link to Django choices documentation * Add test and documentation note That setting to an empty list is the same as setting the value as False * Fix Django warning in tests * rst is not markdown --- docs/queries.rst | 65 ++++++++++++++ graphene_django/converter.py | 6 +- graphene_django/tests/test_converter.py | 17 ++++ graphene_django/tests/test_types.py | 115 +++++++++++++++++++++++- graphene_django/types.py | 23 ++++- 5 files changed, 220 insertions(+), 6 deletions(-) diff --git a/docs/queries.rst b/docs/queries.rst index 0edd1dd..7aff572 100644 --- a/docs/queries.rst +++ b/docs/queries.rst @@ -92,6 +92,71 @@ You can completely overwrite a field, or add new fields, to a ``DjangoObjectType return 'hello!' +Choices to Enum conversion +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +By default Graphene-Django will convert any Django fields that have `choices`_ +defined into a GraphQL enum type. + +.. _choices: https://docs.djangoproject.com/en/2.2/ref/models/fields/#choices + +For example the following ``Model`` and ``DjangoObjectType``: + +.. code:: python + + class PetModel(models.Model): + kind = models.CharField(max_length=100, choices=(('cat', 'Cat'), ('dog', 'Dog'))) + + class Pet(DjangoObjectType): + class Meta: + model = PetModel + +Results in the following GraphQL schema definition: + +.. code:: + + type Pet { + id: ID! + kind: PetModelKind! + } + + enum PetModelKind { + CAT + DOG + } + +You can disable this automatic conversion by setting +``convert_choices_to_enum`` attribute to ``False`` on the ``DjangoObjectType`` +``Meta`` class. + +.. code:: python + + class Pet(DjangoObjectType): + class Meta: + model = PetModel + convert_choices_to_enum = False + +.. code:: + + 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'] + +**Note:** Setting ``convert_choices_to_enum = []`` is the same as setting it to +``False``. + + Related models -------------- diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 1bb16f4..4d0b45f 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -52,13 +52,15 @@ def get_choices(choices): yield name, value, description -def convert_django_field_with_choices(field, registry=None): +def convert_django_field_with_choices( + field, registry=None, convert_choices_to_enum=True +): if registry is not None: converted = registry.get_converted_field(field) if converted: return converted choices = getattr(field, "choices", None) - if choices: + if choices and convert_choices_to_enum: meta = field.model._meta name = to_camel_case("{}_{}".format(meta.object_name, field.name)) choices = list(get_choices(choices)) diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index bb176b3..5542c90 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -196,6 +196,23 @@ def test_field_with_choices_collision(): convert_django_field_with_choices(field) +def test_field_with_choices_convert_enum_false(): + field = models.CharField( + help_text="Language", choices=(("es", "Spanish"), ("en", "English")) + ) + + class TranslatedModel(models.Model): + language = field + + class Meta: + app_label = "test" + + graphene_type = convert_django_field_with_choices( + field, convert_choices_to_enum=False + ) + assert isinstance(graphene_type, graphene.String) + + def test_should_float_convert_float(): assert_conversion(models.FloatField, graphene.Float) diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 8a8643b..c1ac6c2 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -1,6 +1,11 @@ +from collections import OrderedDict, defaultdict +from textwrap import dedent + +import pytest +from django.db import models from mock import patch -from graphene import Interface, ObjectType, Schema, Connection, String +from graphene import Connection, Field, Interface, ObjectType, Schema, String from graphene.relay import Node from .. import registry @@ -224,3 +229,111 @@ def test_django_objecttype_exclude_fields(): fields = list(Reporter._meta.fields.keys()) assert "email" not in fields + + +class TestDjangoObjectType: + @pytest.fixture + def PetModel(self): + class PetModel(models.Model): + kind = models.CharField(choices=(("cat", "Cat"), ("dog", "Dog"))) + cuteness = models.IntegerField( + choices=((1, "Kind of cute"), (2, "Pretty cute"), (3, "OMG SO CUTE!!!")) + ) + + yield PetModel + + # Clear Django model cache so we don't get warnings when creating the + # model multiple times + PetModel._meta.apps.all_models = defaultdict(OrderedDict) + + def test_django_objecttype_convert_choices_enum_false(self, PetModel): + class Pet(DjangoObjectType): + class Meta: + model = PetModel + convert_choices_to_enum = False + + class Query(ObjectType): + pet = Field(Pet) + + schema = Schema(query=Query) + + assert str(schema) == dedent( + """\ + schema { + query: Query + } + + type Pet { + id: ID! + kind: String! + cuteness: Int! + } + + type Query { + pet: Pet + } + """ + ) + + def test_django_objecttype_convert_choices_enum_list(self, PetModel): + class Pet(DjangoObjectType): + class Meta: + model = PetModel + convert_choices_to_enum = ["kind"] + + class Query(ObjectType): + pet = Field(Pet) + + schema = Schema(query=Query) + + assert str(schema) == dedent( + """\ + schema { + query: Query + } + + type Pet { + id: ID! + kind: PetModelKind! + cuteness: Int! + } + + enum PetModelKind { + CAT + DOG + } + + type Query { + pet: Pet + } + """ + ) + + def test_django_objecttype_convert_choices_enum_empty_list(self, PetModel): + class Pet(DjangoObjectType): + class Meta: + model = PetModel + convert_choices_to_enum = [] + + class Query(ObjectType): + pet = Field(Pet) + + schema = Schema(query=Query) + + assert str(schema) == dedent( + """\ + schema { + query: Query + } + + type Pet { + id: ID! + kind: String! + cuteness: Int! + } + + type Query { + pet: Pet + } + """ + ) diff --git a/graphene_django/types.py b/graphene_django/types.py index a1e17b3..005300d 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -18,7 +18,9 @@ if six.PY3: from typing import Type -def construct_fields(model, registry, only_fields, exclude_fields): +def construct_fields( + model, registry, only_fields, exclude_fields, convert_choices_to_enum +): _model_fields = get_model_fields(model) fields = OrderedDict() @@ -33,7 +35,18 @@ def construct_fields(model, registry, only_fields, exclude_fields): # in there. Or when we exclude this field in exclude_fields. # Or when there is no back reference. continue - converted = convert_django_field_with_choices(field, registry) + + _convert_choices_to_enum = convert_choices_to_enum + if not isinstance(_convert_choices_to_enum, bool): + # then `convert_choices_to_enum` is a list of field names to convert + if name in _convert_choices_to_enum: + _convert_choices_to_enum = True + else: + _convert_choices_to_enum = False + + converted = convert_django_field_with_choices( + field, registry, convert_choices_to_enum=_convert_choices_to_enum + ) fields[name] = converted return fields @@ -63,6 +76,7 @@ class DjangoObjectType(ObjectType): connection_class=None, use_connection=None, interfaces=(), + convert_choices_to_enum=True, _meta=None, **options ): @@ -90,7 +104,10 @@ class DjangoObjectType(ObjectType): ) django_fields = yank_fields_from_attrs( - construct_fields(model, registry, only_fields, exclude_fields), _as=Field + construct_fields( + model, registry, only_fields, exclude_fields, convert_choices_to_enum + ), + _as=Field, ) if use_connection is None and interfaces: From 91c1278d1a25e35c08c47d24e6ac39ecc0ab78e2 Mon Sep 17 00:00:00 2001 From: Semyon Pupkov Date: Wed, 19 Jun 2019 15:59:19 +0500 Subject: [PATCH 05/28] Make cookbook example working on django 2 (#680) --- examples/cookbook/cookbook/ingredients/models.py | 4 +++- examples/cookbook/cookbook/recipes/models.py | 6 ++++-- examples/cookbook/cookbook/settings.py | 3 +-- examples/cookbook/requirements.txt | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/examples/cookbook/cookbook/ingredients/models.py b/examples/cookbook/cookbook/ingredients/models.py index 6426dab..1e97226 100644 --- a/examples/cookbook/cookbook/ingredients/models.py +++ b/examples/cookbook/cookbook/ingredients/models.py @@ -11,7 +11,9 @@ class Category(models.Model): class Ingredient(models.Model): name = models.CharField(max_length=100) notes = models.TextField(null=True, blank=True) - category = models.ForeignKey(Category, related_name="ingredients") + category = models.ForeignKey( + Category, related_name="ingredients", on_delete=models.CASCADE + ) def __str__(self): return self.name diff --git a/examples/cookbook/cookbook/recipes/models.py b/examples/cookbook/cookbook/recipes/models.py index b98664c..0bfb434 100644 --- a/examples/cookbook/cookbook/recipes/models.py +++ b/examples/cookbook/cookbook/recipes/models.py @@ -10,8 +10,10 @@ class Recipe(models.Model): class RecipeIngredient(models.Model): - recipe = models.ForeignKey(Recipe, related_name="amounts") - ingredient = models.ForeignKey(Ingredient, related_name="used_by") + recipe = models.ForeignKey(Recipe, related_name="amounts", on_delete=models.CASCADE) + ingredient = models.ForeignKey( + Ingredient, related_name="used_by", on_delete=models.CASCADE + ) amount = models.FloatField() unit = models.CharField( max_length=20, diff --git a/examples/cookbook/cookbook/settings.py b/examples/cookbook/cookbook/settings.py index ed41a65..7eb9d56 100644 --- a/examples/cookbook/cookbook/settings.py +++ b/examples/cookbook/cookbook/settings.py @@ -43,13 +43,12 @@ INSTALLED_APPS = [ "cookbook.recipes.apps.RecipesConfig", ] -MIDDLEWARE_CLASSES = [ +MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.auth.middleware.SessionAuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] diff --git a/examples/cookbook/requirements.txt b/examples/cookbook/requirements.txt index 9d13a82..ccece5c 100644 --- a/examples/cookbook/requirements.txt +++ b/examples/cookbook/requirements.txt @@ -1,5 +1,5 @@ graphene graphene-django graphql-core>=2.1rc1 -django==1.11.21 +django==2.2.2 django-filter>=2 From 692540cc782e52364f01c14523bcd551dff6cd3e Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Mon, 24 Jun 2019 18:55:44 +0100 Subject: [PATCH 06/28] Update flake8 (#688) * Include setup.py in black formatting * Add new flake8 plugins and update errors to look for * Fix duplicate test name * Don't use mutable data structure * Install all dev dependencies for flake8 and black tox envs --- Makefile | 2 +- graphene_django/filter/tests/test_fields.py | 4 ++- graphene_django/rest_framework/mutation.py | 2 +- setup.cfg | 27 ++++++++++++++++++++- setup.py | 8 +++++- tox.ini | 6 ++--- 6 files changed, 41 insertions(+), 8 deletions(-) diff --git a/Makefile b/Makefile index 39a0f31..b850ae8 100644 --- a/Makefile +++ b/Makefile @@ -14,7 +14,7 @@ test: tests # Alias test -> tests .PHONY: format format: - black --exclude "/migrations/" graphene_django examples + black --exclude "/migrations/" graphene_django examples setup.py .PHONY: lint lint: diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index 4d8d597..b9bc599 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -321,12 +321,14 @@ def test_filter_filterset_related_results(): 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 = """ @@ -450,7 +452,7 @@ def test_global_id_multiple_field_explicit_reverse(): assert multiple_filter.field_class == GlobalIDMultipleChoiceField -def test_filter_filterset_related_results(): +def test_filter_filterset_related_results_with_filter(): class ReporterFilterNode(DjangoObjectType): class Meta: model = Reporter diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py index 0fe9a02..b5e7160 100644 --- a/graphene_django/rest_framework/mutation.py +++ b/graphene_django/rest_framework/mutation.py @@ -52,7 +52,7 @@ class SerializerMutation(ClientIDMutation): lookup_field=None, serializer_class=None, model_class=None, - model_operations=["create", "update"], + model_operations=("create", "update"), only_fields=(), exclude_fields=(), **options diff --git a/setup.cfg b/setup.cfg index 546ad67..7d93d3e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,8 +5,33 @@ test=pytest universal=1 [flake8] -exclude = setup.py,docs/*,examples/*,tests,graphene_django/debug/sql/* +exclude = docs,graphene_django/debug/sql/*,migrations max-line-length = 120 +select = + # Dictionary key repeated + F601, + # Ensure use of ==/!= to compare with str, bytes and int literals + F632, + # Redefinition of unused name + F811, + # Using an undefined variable + F821, + # Defining an undefined variable in __all__ + F822, + # Using a variable before it is assigned + F823, + # Duplicate argument in function declaration + F831, + # Black would format this line + BLK, + # Do not use bare except + B001, + # Don't allow ++n. You probably meant n += 1 + B002, + # Do not use mutable structures for argument defaults + B006, + # Do not perform calls in argument defaults + B008 [coverage:run] omit = */tests/* diff --git a/setup.py b/setup.py index e622a71..bc7dcd3 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,8 @@ tests_require = [ dev_requires = [ "black==19.3b0", "flake8==3.7.7", + "flake8-black==0.1.0", + "flake8-bugbear==19.3.0", ] + tests_require setup( @@ -64,7 +66,11 @@ setup( setup_requires=["pytest-runner"], tests_require=tests_require, rest_framework_require=rest_framework_require, - extras_require={"test": tests_require, "rest_framework": rest_framework_require, "dev": dev_requires}, + extras_require={ + "test": tests_require, + "rest_framework": rest_framework_require, + "dev": dev_requires, + }, include_package_data=True, zip_safe=False, platforms="any", diff --git a/tox.ini b/tox.ini index 58f283a..a1b599a 100644 --- a/tox.ini +++ b/tox.ini @@ -28,12 +28,12 @@ commands = {posargs:py.test --cov=graphene_django graphene_django examples} [testenv:black] basepython = python3.7 -deps = black +deps = -e.[dev] commands = - black --exclude "/migrations/" graphene_django examples --check + black --exclude "/migrations/" graphene_django examples setup.py --check [testenv:flake8] basepython = python3.7 -deps = flake8 +deps = -e.[dev] commands = flake8 graphene_django examples From e2e496f505bad4d45a1616baa176a53732766bd1 Mon Sep 17 00:00:00 2001 From: Konstantin Alekseev Date: Tue, 25 Jun 2019 11:40:29 +0300 Subject: [PATCH 07/28] Apply camel case converter to field names in DRF errors (#514) * Apply camel case converter to field names in DRF errors * Implement recursive error camelize, add setting. --- graphene_django/forms/mutation.py | 7 ++--- graphene_django/forms/tests/test_mutation.py | 20 +++++++++++++- graphene_django/rest_framework/mutation.py | 9 +++---- .../rest_framework/tests/test_mutation.py | 14 +++++++--- graphene_django/settings.py | 1 + graphene_django/tests/test_utils.py | 22 +++++++++++++++- graphene_django/types.py | 21 ++++++++++++--- graphene_django/utils/__init__.py | 10 ++++--- graphene_django/utils/utils.py | 26 +++++++++++++++++++ 9 files changed, 107 insertions(+), 23 deletions(-) diff --git a/graphene_django/forms/mutation.py b/graphene_django/forms/mutation.py index 0851a75..f5921e8 100644 --- a/graphene_django/forms/mutation.py +++ b/graphene_django/forms/mutation.py @@ -13,8 +13,8 @@ from graphene.types.mutation import MutationOptions from graphene.types.utils import yank_fields_from_attrs from graphene_django.registry import get_global_registry -from .converter import convert_form_field from ..types import ErrorType +from .converter import convert_form_field def fields_for_form(form, only_fields, exclude_fields): @@ -45,10 +45,7 @@ class BaseDjangoFormMutation(ClientIDMutation): if form.is_valid(): return cls.perform_mutate(form, info) else: - errors = [ - ErrorType(field=key, messages=value) - for key, value in form.errors.items() - ] + errors = ErrorType.from_errors(form.errors) return cls(errors=errors) diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py index 543e89e..4c46702 100644 --- a/graphene_django/forms/tests/test_mutation.py +++ b/graphene_django/forms/tests/test_mutation.py @@ -2,7 +2,9 @@ from django import forms from django.test import TestCase from py.test import raises -from graphene_django.tests.models import Pet, Film, FilmDetails +from graphene_django.tests.models import Film, FilmDetails, Pet + +from ...settings import graphene_settings from ..mutation import DjangoFormMutation, DjangoModelFormMutation @@ -41,6 +43,22 @@ def test_has_input_fields(): assert "text" in MyMutation.Input._meta.fields +def test_mutation_error_camelcased(): + class ExtraPetForm(PetForm): + test_field = forms.CharField(required=True) + + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = ExtraPetForm + + result = PetMutation.mutate_and_get_payload(None, None) + assert {f.field for f in result.errors} == {"name", "age", "test_field"} + graphene_settings.DJANGO_GRAPHENE_CAMELCASE_ERRORS = True + result = PetMutation.mutate_and_get_payload(None, None) + assert {f.field for f in result.errors} == {"name", "age", "testField"} + graphene_settings.DJANGO_GRAPHENE_CAMELCASE_ERRORS = False + + class ModelFormMutationTests(TestCase): def test_default_meta_fields(self): class PetMutation(DjangoModelFormMutation): diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py index b5e7160..d9c695e 100644 --- a/graphene_django/rest_framework/mutation.py +++ b/graphene_django/rest_framework/mutation.py @@ -3,13 +3,13 @@ from collections import OrderedDict from django.shortcuts import get_object_or_404 import graphene +from graphene.relay.mutation import ClientIDMutation from graphene.types import Field, InputField from graphene.types.mutation import MutationOptions -from graphene.relay.mutation import ClientIDMutation from graphene.types.objecttype import yank_fields_from_attrs -from .serializer_converter import convert_serializer_field from ..types import ErrorType +from .serializer_converter import convert_serializer_field class SerializerMutationOptions(MutationOptions): @@ -127,10 +127,7 @@ class SerializerMutation(ClientIDMutation): if serializer.is_valid(): return cls.perform_mutate(serializer, info) else: - errors = [ - ErrorType(field=key, messages=value) - for key, value in serializer.errors.items() - ] + errors = ErrorType.from_errors(serializer.errors) return cls(errors=errors) diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py index 9621ee3..0dd5ad3 100644 --- a/graphene_django/rest_framework/tests/test_mutation.py +++ b/graphene_django/rest_framework/tests/test_mutation.py @@ -1,11 +1,12 @@ import datetime +from py.test import mark, raises +from rest_framework import serializers + from graphene import Field, ResolveInfo from graphene.types.inputobjecttype import InputObjectType -from py.test import raises -from py.test import mark -from rest_framework import serializers +from ...settings import graphene_settings from ...types import DjangoObjectType from ..models import MyFakeModel, MyFakeModelWithPassword from ..mutation import SerializerMutation @@ -213,6 +214,13 @@ def test_model_mutate_and_get_payload_error(): assert len(result.errors) > 0 +def test_mutation_error_camelcased(): + graphene_settings.DJANGO_GRAPHENE_CAMELCASE_ERRORS = True + result = MyModelMutation.mutate_and_get_payload(None, mock_info(), **{}) + assert result.errors[0].field == "coolName" + graphene_settings.DJANGO_GRAPHENE_CAMELCASE_ERRORS = False + + def test_invalid_serializer_operations(): with raises(Exception) as exc: diff --git a/graphene_django/settings.py b/graphene_django/settings.py index e5fad78..1b49dfb 100644 --- a/graphene_django/settings.py +++ b/graphene_django/settings.py @@ -35,6 +35,7 @@ DEFAULTS = { "RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST": False, # Max items returned in ConnectionFields / FilterConnectionFields "RELAY_CONNECTION_MAX_LIMIT": 100, + "DJANGO_GRAPHENE_CAMELCASE_ERRORS": False, } if settings.DEBUG: diff --git a/graphene_django/tests/test_utils.py b/graphene_django/tests/test_utils.py index becd031..55cfd4f 100644 --- a/graphene_django/tests/test_utils.py +++ b/graphene_django/tests/test_utils.py @@ -1,4 +1,6 @@ -from ..utils import get_model_fields +from django.utils.translation import gettext_lazy + +from ..utils import camelize, get_model_fields from .models import Film, Reporter @@ -10,3 +12,21 @@ def test_get_model_fields_no_duplication(): film_fields = get_model_fields(Film) film_name_set = set([field[0] for field in film_fields]) assert len(film_fields) == len(film_name_set) + + +def test_camelize(): + assert camelize({}) == {} + assert camelize("value_a") == "value_a" + assert camelize({"value_a": "value_b"}) == {"valueA": "value_b"} + assert camelize({"value_a": ["value_b"]}) == {"valueA": ["value_b"]} + assert camelize({"value_a": ["value_b"]}) == {"valueA": ["value_b"]} + assert camelize({"nested_field": {"value_a": ["error"], "value_b": ["error"]}}) == { + "nestedField": {"valueA": ["error"], "valueB": ["error"]} + } + assert camelize({"value_a": gettext_lazy("value_b")}) == {"valueA": "value_b"} + assert camelize({"value_a": [gettext_lazy("value_b")]}) == {"valueA": ["value_b"]} + assert camelize(gettext_lazy("value_a")) == "value_a" + assert camelize({gettext_lazy("value_a"): gettext_lazy("value_b")}) == { + "valueA": "value_b" + } + assert camelize({0: {"field_a": ["errors"]}}) == {0: {"fieldA": ["errors"]}} diff --git a/graphene_django/types.py b/graphene_django/types.py index 005300d..c296707 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -1,8 +1,9 @@ -import six from collections import OrderedDict +import six from django.db.models import Model from django.utils.functional import SimpleLazyObject + import graphene from graphene import Field from graphene.relay import Connection, Node @@ -11,8 +12,13 @@ from graphene.types.utils import yank_fields_from_attrs from .converter import convert_django_field_with_choices from .registry import Registry, get_global_registry -from .utils import DJANGO_FILTER_INSTALLED, get_model_fields, is_valid_django_model - +from .settings import graphene_settings +from .utils import ( + DJANGO_FILTER_INSTALLED, + camelize, + get_model_fields, + is_valid_django_model, +) if six.PY3: from typing import Type @@ -182,3 +188,12 @@ class DjangoObjectType(ObjectType): class ErrorType(ObjectType): field = graphene.String(required=True) messages = graphene.List(graphene.NonNull(graphene.String), required=True) + + @classmethod + def from_errors(cls, errors): + data = ( + camelize(errors) + if graphene_settings.DJANGO_GRAPHENE_CAMELCASE_ERRORS + else errors + ) + return [ErrorType(field=key, messages=value) for key, value in data.items()] diff --git a/graphene_django/utils/__init__.py b/graphene_django/utils/__init__.py index f9c388d..9d8658b 100644 --- a/graphene_django/utils/__init__.py +++ b/graphene_django/utils/__init__.py @@ -1,18 +1,20 @@ +from .testing import GraphQLTestCase from .utils import ( DJANGO_FILTER_INSTALLED, - get_reverse_fields, - maybe_queryset, + camelize, get_model_fields, - is_valid_django_model, + get_reverse_fields, import_single_dispatch, + is_valid_django_model, + maybe_queryset, ) -from .testing import GraphQLTestCase __all__ = [ "DJANGO_FILTER_INSTALLED", "get_reverse_fields", "maybe_queryset", "get_model_fields", + "camelize", "is_valid_django_model", "import_single_dispatch", "GraphQLTestCase", diff --git a/graphene_django/utils/utils.py b/graphene_django/utils/utils.py index b8aaba0..47c0c37 100644 --- a/graphene_django/utils/utils.py +++ b/graphene_django/utils/utils.py @@ -2,7 +2,11 @@ import inspect from django.db import models from django.db.models.manager import Manager +from django.utils import six +from django.utils.encoding import force_text +from django.utils.functional import Promise +from graphene.utils.str_converters import to_camel_case try: import django_filters # noqa @@ -12,6 +16,28 @@ except ImportError: DJANGO_FILTER_INSTALLED = False +def isiterable(value): + try: + iter(value) + except TypeError: + return False + return True + + +def _camelize_django_str(s): + if isinstance(s, Promise): + s = force_text(s) + return to_camel_case(s) if isinstance(s, six.string_types) else s + + +def camelize(data): + if isinstance(data, dict): + return {_camelize_django_str(k): camelize(v) for k, v in data.items()} + if isiterable(data) and not isinstance(data, (six.string_types, Promise)): + return [camelize(d) for d in data] + return data + + def get_reverse_fields(model, local_field_names): for name, attr in model.__dict__.items(): # Don't duplicate any local fields From 54cc6a4b13c18b8efebccaacd8ac8df93bf56949 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Tue, 25 Jun 2019 16:30:30 +0100 Subject: [PATCH 08/28] Enforce NonNull for returned related Sets and their content (#690) * Enforce NonNull for returned related Sets and their content. https://github.com/graphql-python/graphene-django/issues/448 * Run format. * Remove duplicate assertion --- graphene_django/converter.py | 6 +++++- graphene_django/fields.py | 3 ++- graphene_django/tests/test_converter.py | 16 ++++++++++++---- graphene_django/tests/test_types.py | 2 +- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 4d0b45f..64bf341 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -198,7 +198,11 @@ def convert_field_to_list_or_connection(field, registry=None): return DjangoConnectionField(_type, description=description) - return DjangoListField(_type, description=description) + return DjangoListField( + _type, + required=True, # A Set is always returned, never None. + description=description, + ) return Dynamic(dynamic_type) diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 791e785..8c8fa2b 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -15,7 +15,8 @@ from .utils import maybe_queryset class DjangoListField(Field): def __init__(self, _type, *args, **kwargs): - super(DjangoListField, self).__init__(List(_type), *args, **kwargs) + # Django would never return a Set of None vvvvvvv + super(DjangoListField, self).__init__(List(NonNull(_type)), *args, **kwargs) @property def model(self): diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index 5542c90..00467b4 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -1,6 +1,7 @@ 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 @@ -234,8 +235,12 @@ def test_should_manytomany_convert_connectionorlist_list(): assert isinstance(graphene_field, graphene.Dynamic) dynamic_field = graphene_field.get_type() assert isinstance(dynamic_field, graphene.Field) - assert isinstance(dynamic_field.type, graphene.List) - assert dynamic_field.type.of_type == A + # A NonNull List of NonNull A ([A!]!) + # https://github.com/graphql-python/graphene-django/issues/448 + assert isinstance(dynamic_field.type, NonNull) + assert isinstance(dynamic_field.type.of_type, graphene.List) + assert isinstance(dynamic_field.type.of_type.of_type, NonNull) + assert dynamic_field.type.of_type.of_type.of_type == A def test_should_manytomany_convert_connectionorlist_connection(): @@ -262,8 +267,11 @@ def test_should_manytoone_convert_connectionorlist(): assert isinstance(graphene_field, graphene.Dynamic) dynamic_field = graphene_field.get_type() assert isinstance(dynamic_field, graphene.Field) - assert isinstance(dynamic_field.type, graphene.List) - assert dynamic_field.type.of_type == A + # a NonNull List of NonNull A ([A!]!) + assert isinstance(dynamic_field.type, NonNull) + assert isinstance(dynamic_field.type.of_type, graphene.List) + assert isinstance(dynamic_field.type.of_type.of_type, NonNull) + assert dynamic_field.type.of_type.of_type.of_type == A def test_should_onetoone_reverse_convert_model(): diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index c1ac6c2..6f5ab7e 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -170,7 +170,7 @@ type Reporter { firstName: String! lastName: String! email: String! - pets: [Reporter] + pets: [Reporter!]! aChoice: ReporterAChoice! reporterType: ReporterReporterType articles(before: String, after: String, first: Int, last: Int): ArticleConnection From 40ae7e53ec4d8be5e540ab26e110506733ea2b9b Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Tue, 2 Jul 2019 19:37:50 +0100 Subject: [PATCH 09/28] Fix manager check in DjangoConnectionField (#693) * Fix default manager check * Add test --- graphene_django/fields.py | 2 +- graphene_django/tests/test_query.py | 51 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 8c8fa2b..eb1215e 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -101,7 +101,7 @@ class DjangoConnectionField(ConnectionField): iterable = default_manager iterable = maybe_queryset(iterable) if isinstance(iterable, QuerySet): - if iterable is not default_manager: + if iterable.model.objects is not default_manager: default_queryset = maybe_queryset(default_manager) iterable = cls.merge_querysets(default_queryset, iterable) _len = iterable.count() diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 484a225..f466122 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1065,3 +1065,54 @@ def test_should_resolve_get_queryset_connectionfields(): result = schema.execute(query) assert not result.errors assert result.data == expected + + +def test_should_preserve_prefetch_related(django_assert_num_queries): + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (graphene.relay.Node,) + + class FilmType(DjangoObjectType): + reporters = DjangoConnectionField(ReporterType) + + class Meta: + model = Film + interfaces = (graphene.relay.Node,) + + class Query(graphene.ObjectType): + films = DjangoConnectionField(FilmType) + + def resolve_films(root, info): + qs = Film.objects.prefetch_related("reporters") + return qs + + r1 = Reporter.objects.create(first_name="Dave", last_name="Smith") + r2 = Reporter.objects.create(first_name="Jane", last_name="Doe") + + f1 = Film.objects.create() + f1.reporters.set([r1, r2]) + f2 = Film.objects.create() + f2.reporters.set([r2]) + + query = """ + query { + films { + edges { + node { + reporters { + edges { + node { + firstName + } + } + } + } + } + } + } + """ + schema = graphene.Schema(query=Query) + with django_assert_num_queries(3) as captured: + result = schema.execute(query) + assert not result.errors From 470fb60dc5341b26a6069c29c6c3c12b4146ccdb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jul 2019 10:26:27 +0100 Subject: [PATCH 10/28] Bump django from 2.1.9 to 2.1.10 in /examples/cookbook-plain (#695) Bumps [django](https://github.com/django/django) from 2.1.9 to 2.1.10. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.1.9...2.1.10) Signed-off-by: dependabot[bot] --- 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 ea1f4ba..1dc8fcd 100644 --- a/examples/cookbook-plain/requirements.txt +++ b/examples/cookbook-plain/requirements.txt @@ -1,4 +1,4 @@ graphene graphene-django graphql-core>=2.1rc1 -django==2.1.9 +django==2.1.10 From 3b541e3d05d0ca8f15a138d9daa4d347019c02b8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 4 Jul 2019 10:26:54 +0100 Subject: [PATCH 11/28] Bump django from 2.2.2 to 2.2.3 in /examples/cookbook (#694) Bumps [django](https://github.com/django/django) from 2.2.2 to 2.2.3. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.2.2...2.2.3) Signed-off-by: dependabot[bot] --- 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 ccece5c..49470ed 100644 --- a/examples/cookbook/requirements.txt +++ b/examples/cookbook/requirements.txt @@ -1,5 +1,5 @@ graphene graphene-django graphql-core>=2.1rc1 -django==2.2.2 +django==2.2.3 django-filter>=2 From 9aabe2cbe62f412ee70ad9b0b47a15d28021b80e Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sun, 7 Jul 2019 20:06:01 +0100 Subject: [PATCH 12/28] Remove duplicate ErrorType (#701) --- graphene_django/forms/types.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/graphene_django/forms/types.py b/graphene_django/forms/types.py index 1fe33f3..5005040 100644 --- a/graphene_django/forms/types.py +++ b/graphene_django/forms/types.py @@ -1,6 +1,3 @@ import graphene - -class ErrorType(graphene.ObjectType): - field = graphene.String() - messages = graphene.List(graphene.String) +from ..types import ErrorType # noqa Import ErrorType for backwards compatability From aa30750d395dc1cc5f550d933506d978c20d285e Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sun, 7 Jul 2019 20:11:27 +0100 Subject: [PATCH 13/28] Bugfix: Correct filter types for DjangoFilterConnectionFields (#682) * Get form field from Django model before defaulting to django-filter * Add test * Cleanup some flake8 warnings and pytest warnings * Run isort and add black compatible config --- graphene_django/filter/tests/test_fields.py | 73 +++++++++++++++++---- graphene_django/filter/utils.py | 19 +++++- setup.cfg | 5 ++ 3 files changed, 85 insertions(+), 12 deletions(-) diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index b9bc599..d163ff3 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -1,18 +1,17 @@ from datetime import datetime +from textwrap import dedent import pytest +from django.db.models import TextField, Value +from django.db.models.functions import Concat -from graphene import Field, ObjectType, Schema, Argument, Float, Boolean, String +from graphene import Argument, Boolean, Field, Float, ObjectType, Schema, String from graphene.relay import Node from graphene_django import DjangoObjectType from graphene_django.forms import GlobalIDFormField, GlobalIDMultipleChoiceField from graphene_django.tests.models import Article, Pet, Reporter from graphene_django.utils import DJANGO_FILTER_INSTALLED -# for annotation test -from django.db.models import TextField, Value -from django.db.models.functions import Concat - pytestmark = [] if DJANGO_FILTER_INSTALLED: @@ -183,7 +182,7 @@ def test_filter_shortcut_filterset_context(): } """ schema = Schema(query=Query) - result = schema.execute(query, context_value=context()) + result = schema.execute(query, context=context()) assert not result.errors assert len(result.data["contextArticles"]["edges"]) == 1 @@ -462,15 +461,15 @@ def test_filter_filterset_related_results_with_filter(): class Query(ObjectType): all_reporters = DjangoFilterConnectionField(ReporterFilterNode) - r1 = Reporter.objects.create( + Reporter.objects.create( first_name="A test user", last_name="Last Name", email="test1@test.com" ) - r2 = Reporter.objects.create( + Reporter.objects.create( first_name="Other test user", last_name="Other Last Name", email="test2@test.com", ) - r3 = Reporter.objects.create( + Reporter.objects.create( first_name="Random", last_name="RandomLast", email="random@test.com" ) @@ -638,7 +637,7 @@ def test_should_query_filter_node_double_limit_raises(): Reporter.objects.create( first_name="Bob", last_name="Doe", email="bobdoe@example.com", a_choice=2 ) - r = Reporter.objects.create( + Reporter.objects.create( first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 ) @@ -684,7 +683,7 @@ def test_order_by_is_perserved(): return reporters Reporter.objects.create(first_name="b") - r = Reporter.objects.create(first_name="a") + Reporter.objects.create(first_name="a") schema = Schema(query=Query) query = """ @@ -767,3 +766,55 @@ def test_annotation_is_perserved(): assert not result.errors assert result.data == expected + + +def test_integer_field_filter_type(): + class PetType(DjangoObjectType): + class Meta: + model = Pet + interfaces = (Node,) + filter_fields = {"age": ["exact"]} + only_fields = ["age"] + + class Query(ObjectType): + pets = DjangoFilterConnectionField(PetType) + + schema = Schema(query=Query) + + assert str(schema) == dedent( + """\ + schema { + query: Query + } + + interface Node { + id: ID! + } + + type PageInfo { + hasNextPage: Boolean! + hasPreviousPage: Boolean! + startCursor: String + endCursor: String + } + + type PetType implements Node { + age: Int! + id: ID! + } + + type PetTypeConnection { + pageInfo: PageInfo! + edges: [PetTypeEdge]! + } + + type PetTypeEdge { + node: PetType + cursor: String! + } + + type Query { + pets(before: String, after: String, first: Int, last: Int, age: Int): PetTypeConnection + } + """ + ) diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index cfa5621..00030a0 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -11,8 +11,25 @@ def get_filtering_args_from_filterset(filterset_class, type): from ..forms.converter import convert_form_field args = {} + model = filterset_class._meta.model for name, filter_field in six.iteritems(filterset_class.base_filters): - field_type = convert_form_field(filter_field.field).Argument() + if name in filterset_class.declared_filters: + form_field = filter_field.field + else: + field_name = name.split("__", 1)[0] + model_field = model._meta.get_field(field_name) + + if hasattr(model_field, "formfield"): + form_field = model_field.formfield( + required=filter_field.extra.get("required", False) + ) + + # Fallback to field defined on filter if we can't get it from the + # model field + if not form_field: + form_field = filter_field.field + + field_type = convert_form_field(form_field).Argument() field_type.description = filter_field.label args[name] = field_type diff --git a/setup.cfg b/setup.cfg index 7d93d3e..def0b67 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,3 +38,8 @@ omit = */tests/* [isort] known_first_party=graphene,graphene_django +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 From 0988e0798ac72a8ebca1b9c133bb31648b3b582b Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Mon, 8 Jul 2019 22:22:08 +0100 Subject: [PATCH 14/28] Adds documentation to `CAMELCASE_ERRORS` setting (#689) * Rename setting and add documentation * Add examples * Use `cls` --- docs/settings.rst | 39 +++++++++++++++++++ graphene_django/forms/tests/test_mutation.py | 4 +- .../rest_framework/tests/test_mutation.py | 4 +- graphene_django/settings.py | 2 +- graphene_django/types.py | 8 +--- 5 files changed, 46 insertions(+), 11 deletions(-) diff --git a/docs/settings.rst b/docs/settings.rst index 4d37a99..4776ce0 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -101,3 +101,42 @@ Default: ``100`` GRAPHENE = { 'RELAY_CONNECTION_MAX_LIMIT': 100, } + + +``CAMELCASE_ERRORS`` +------------------------------------ + +When set to ``True`` field names in the ``errors`` object will be camel case. +By default they will be snake case. + +Default: ``False`` + +.. code:: python + + GRAPHENE = { + 'CAMELCASE_ERRORS': False, + } + + # result = schema.execute(...) + print(result.errors) + # [ + # { + # 'field': 'test_field', + # 'messages': ['This field is required.'], + # } + # ] + +.. code:: python + + GRAPHENE = { + 'CAMELCASE_ERRORS': True, + } + + # result = schema.execute(...) + print(result.errors) + # [ + # { + # 'field': 'testField', + # 'messages': ['This field is required.'], + # } + # ] diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py index 4c46702..2de5113 100644 --- a/graphene_django/forms/tests/test_mutation.py +++ b/graphene_django/forms/tests/test_mutation.py @@ -53,10 +53,10 @@ def test_mutation_error_camelcased(): result = PetMutation.mutate_and_get_payload(None, None) assert {f.field for f in result.errors} == {"name", "age", "test_field"} - graphene_settings.DJANGO_GRAPHENE_CAMELCASE_ERRORS = True + 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.DJANGO_GRAPHENE_CAMELCASE_ERRORS = False + graphene_settings.CAMELCASE_ERRORS = False class ModelFormMutationTests(TestCase): diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py index 0dd5ad3..9d8b950 100644 --- a/graphene_django/rest_framework/tests/test_mutation.py +++ b/graphene_django/rest_framework/tests/test_mutation.py @@ -215,10 +215,10 @@ def test_model_mutate_and_get_payload_error(): def test_mutation_error_camelcased(): - graphene_settings.DJANGO_GRAPHENE_CAMELCASE_ERRORS = True + graphene_settings.CAMELCASE_ERRORS = True result = MyModelMutation.mutate_and_get_payload(None, mock_info(), **{}) assert result.errors[0].field == "coolName" - graphene_settings.DJANGO_GRAPHENE_CAMELCASE_ERRORS = False + graphene_settings.CAMELCASE_ERRORS = False def test_invalid_serializer_operations(): diff --git a/graphene_django/settings.py b/graphene_django/settings.py index 1b49dfb..af63890 100644 --- a/graphene_django/settings.py +++ b/graphene_django/settings.py @@ -35,7 +35,7 @@ DEFAULTS = { "RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST": False, # Max items returned in ConnectionFields / FilterConnectionFields "RELAY_CONNECTION_MAX_LIMIT": 100, - "DJANGO_GRAPHENE_CAMELCASE_ERRORS": False, + "CAMELCASE_ERRORS": False, } if settings.DEBUG: diff --git a/graphene_django/types.py b/graphene_django/types.py index c296707..6c100ef 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -191,9 +191,5 @@ class ErrorType(ObjectType): @classmethod def from_errors(cls, errors): - data = ( - camelize(errors) - if graphene_settings.DJANGO_GRAPHENE_CAMELCASE_ERRORS - else errors - ) - return [ErrorType(field=key, messages=value) for key, value in data.items()] + data = camelize(errors) if graphene_settings.CAMELCASE_ERRORS else errors + return [cls(field=key, messages=value) for key, value in data.items()] From a2103c19f427888d749be90e525aaec79527300e Mon Sep 17 00:00:00 2001 From: Pablo Burgos Date: Tue, 9 Jul 2019 10:14:04 +0200 Subject: [PATCH 15/28] Fix error of multiple inputs with the same type. When using same serializer. (#530) --- .../rest_framework/serializer_converter.py | 11 +++- .../tests/test_multiple_model_serializers.py | 63 +++++++++++++++++++ 2 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 graphene_django/rest_framework/tests/test_multiple_model_serializers.py diff --git a/graphene_django/rest_framework/serializer_converter.py b/graphene_django/rest_framework/serializer_converter.py index 9f8e516..35c8dc8 100644 --- a/graphene_django/rest_framework/serializer_converter.py +++ b/graphene_django/rest_framework/serializer_converter.py @@ -57,18 +57,25 @@ def convert_serializer_field(field, is_input=True): def convert_serializer_to_input_type(serializer_class): + cached_type = convert_serializer_to_input_type.cache.get(serializer_class.__name__, None) + if cached_type: + return cached_type serializer = serializer_class() items = { name: convert_serializer_field(field) for name, field in serializer.fields.items() } - - return type( + ret_type = type( "{}Input".format(serializer.__class__.__name__), (graphene.InputObjectType,), items, ) + convert_serializer_to_input_type.cache[serializer_class.__name__] = ret_type + return ret_type + + +convert_serializer_to_input_type.cache = {} @get_graphene_type_from_serializer_field.register(serializers.Field) diff --git a/graphene_django/rest_framework/tests/test_multiple_model_serializers.py b/graphene_django/rest_framework/tests/test_multiple_model_serializers.py new file mode 100644 index 0000000..4504610 --- /dev/null +++ b/graphene_django/rest_framework/tests/test_multiple_model_serializers.py @@ -0,0 +1,63 @@ +import graphene +import pytest +from django.db import models +from graphene import Schema +from rest_framework import serializers + +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) + created = models.DateTimeField(auto_now_add=True) + + +class MyFakeParentModel(models.Model): + name = models.CharField(max_length=50) + created = models.DateTimeField(auto_now_add=True) + child1 = models.OneToOneField(MyFakeChildModel, related_name='parent1', on_delete=models.CASCADE) + child2 = models.OneToOneField(MyFakeChildModel, related_name='parent2', on_delete=models.CASCADE) + + +class ParentType(DjangoObjectType): + class Meta: + model = MyFakeParentModel + interfaces = (graphene.relay.Node,) + + +class ChildType(DjangoObjectType): + class Meta: + model = MyFakeChildModel + interfaces = (graphene.relay.Node,) + + +class MyModelChildSerializer(serializers.ModelSerializer): + class Meta: + model = MyFakeChildModel + fields = "__all__" + + +class MyModelParentSerializer(serializers.ModelSerializer): + child1 = MyModelChildSerializer() + child2 = MyModelChildSerializer() + + class Meta: + model = MyFakeParentModel + fields = "__all__" + + +class MyParentModelMutation(SerializerMutation): + class Meta: + serializer_class = MyModelParentSerializer + + +class Mutation(graphene.ObjectType): + createParentWithChild = MyParentModelMutation.Field() + + +def test_create_schema(): + schema = Schema(mutation=Mutation, types=[ParentType, ChildType]) + assert schema From b7e4937775a951c6d3990db58689bd9acee8a222 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Tue, 9 Jul 2019 14:03:11 +0100 Subject: [PATCH 16/28] Alias `only_fields` as `fields` and `exclude_fields` as `exclude` (#691) * Create new fields and exclude options that are aliased to exclude_fields and only_fields * Update docs * Add some checking around fields and exclude definitions * Add all fields option * Update docs to include `__all__` option * Actual order of fields is not stable * Update docs/queries.rst Co-Authored-By: Semyon Pupkov * Fix example code * Format code * Start raising PendingDeprecationWarnings for using only_fields and exclude_fields * Update tests --- docs/queries.rst | 45 ++++++--- graphene_django/filter/tests/test_fields.py | 2 +- .../rest_framework/serializer_converter.py | 4 +- .../tests/test_multiple_model_serializers.py | 8 +- graphene_django/tests/test_query.py | 8 +- graphene_django/tests/test_schema.py | 2 +- graphene_django/tests/test_types.py | 99 +++++++++++++++++-- graphene_django/types.py | 54 +++++++++- 8 files changed, 187 insertions(+), 35 deletions(-) diff --git a/docs/queries.rst b/docs/queries.rst index 7aff572..67ebb06 100644 --- a/docs/queries.rst +++ b/docs/queries.rst @@ -41,14 +41,18 @@ Full example return Question.objects.get(pk=question_id) -Fields ------- +Specifying which fields to include +---------------------------------- By default, ``DjangoObjectType`` will present all fields on a Model through GraphQL. -If you don't want to do this you can change this by setting either ``only_fields`` and ``exclude_fields``. +If you only want a subset of fields to be present, you can do so using +``fields`` or ``exclude``. It is strongly recommended that you explicitly set +all fields that should be exposed using the fields attribute. +This will make it less likely to result in unintentionally exposing data when +your models change. -only_fields -~~~~~~~~~~~ +``fields`` +~~~~~~~~~~ Show **only** these fields on the model: @@ -57,24 +61,35 @@ Show **only** these fields on the model: class QuestionType(DjangoObjectType): class Meta: model = Question - only_fields = ('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. -exclude_fields -~~~~~~~~~~~~~~ - -Show all fields **except** those in ``exclude_fields``: +For example: .. code:: python class QuestionType(DjangoObjectType): class Meta: model = Question - exclude_fields = ('question_text') + fields = '__all__' -Customised fields -~~~~~~~~~~~~~~~~~ +``exclude`` +~~~~~~~~~~~ + +Show all fields **except** those in ``exclude``: + +.. code:: python + + class QuestionType(DjangoObjectType): + class Meta: + model = Question + exclude = ('question_text',) + + +Customising fields +------------------ You can completely overwrite a field, or add new fields, to a ``DjangoObjectType`` using a Resolver: @@ -84,7 +99,7 @@ You can completely overwrite a field, or add new fields, to a ``DjangoObjectType class Meta: model = Question - exclude_fields = ('question_text') + fields = ('id', 'question_text') extra_field = graphene.String() @@ -178,7 +193,7 @@ When ``Question`` is published as a ``DjangoObjectType`` and you want to add ``C class QuestionType(DjangoObjectType): class Meta: model = Question - only_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 diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index d163ff3..99876b6 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -774,7 +774,7 @@ def test_integer_field_filter_type(): model = Pet interfaces = (Node,) filter_fields = {"age": ["exact"]} - only_fields = ["age"] + fields = ("age",) class Query(ObjectType): pets = DjangoFilterConnectionField(PetType) diff --git a/graphene_django/rest_framework/serializer_converter.py b/graphene_django/rest_framework/serializer_converter.py index 35c8dc8..c419419 100644 --- a/graphene_django/rest_framework/serializer_converter.py +++ b/graphene_django/rest_framework/serializer_converter.py @@ -57,7 +57,9 @@ def convert_serializer_field(field, is_input=True): def convert_serializer_to_input_type(serializer_class): - cached_type = convert_serializer_to_input_type.cache.get(serializer_class.__name__, None) + cached_type = convert_serializer_to_input_type.cache.get( + serializer_class.__name__, None + ) if cached_type: return cached_type serializer = serializer_class() 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 4504610..c1f4626 100644 --- a/graphene_django/rest_framework/tests/test_multiple_model_serializers.py +++ b/graphene_django/rest_framework/tests/test_multiple_model_serializers.py @@ -18,8 +18,12 @@ class MyFakeChildModel(models.Model): class MyFakeParentModel(models.Model): name = models.CharField(max_length=50) created = models.DateTimeField(auto_now_add=True) - child1 = models.OneToOneField(MyFakeChildModel, related_name='parent1', on_delete=models.CASCADE) - child2 = models.OneToOneField(MyFakeChildModel, related_name='parent2', on_delete=models.CASCADE) + child1 = models.OneToOneField( + MyFakeChildModel, related_name="parent1", on_delete=models.CASCADE + ) + child2 = models.OneToOneField( + MyFakeChildModel, related_name="parent2", on_delete=models.CASCADE + ) class ParentType(DjangoObjectType): diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index f466122..f24f84b 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -28,7 +28,7 @@ def test_should_query_only_fields(): class ReporterType(DjangoObjectType): class Meta: model = Reporter - only_fields = ("articles",) + fields = ("articles",) schema = graphene.Schema(query=ReporterType) query = """ @@ -44,7 +44,7 @@ def test_should_query_simplelazy_objects(): class ReporterType(DjangoObjectType): class Meta: model = Reporter - only_fields = ("id",) + fields = ("id",) class Query(graphene.ObjectType): reporter = graphene.Field(ReporterType) @@ -289,7 +289,7 @@ def test_should_query_connectionfields(): class Meta: model = Reporter interfaces = (Node,) - only_fields = ("articles",) + fields = ("articles",) class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) @@ -329,7 +329,7 @@ def test_should_keep_annotations(): class Meta: model = Reporter interfaces = (Node,) - only_fields = ("articles",) + fields = ("articles",) class ArticleType(DjangoObjectType): class Meta: diff --git a/graphene_django/tests/test_schema.py b/graphene_django/tests/test_schema.py index 452449b..2c2f74b 100644 --- a/graphene_django/tests/test_schema.py +++ b/graphene_django/tests/test_schema.py @@ -48,6 +48,6 @@ def test_should_map_only_few_fields(): class Reporter2(DjangoObjectType): class Meta: model = Reporter - only_fields = ("id", "email") + fields = ("id", "email") assert list(Reporter2._meta.fields.keys()) == ["id", "email"] diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 6f5ab7e..6cbaae0 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -211,26 +211,113 @@ def with_local_registry(func): @with_local_registry def test_django_objecttype_only_fields(): - class Reporter(DjangoObjectType): - class Meta: - model = ReporterModel - only_fields = ("id", "email", "films") + with pytest.warns(PendingDeprecationWarning): + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + only_fields = ("id", "email", "films") fields = list(Reporter._meta.fields.keys()) assert fields == ["id", "email", "films"] @with_local_registry -def test_django_objecttype_exclude_fields(): +def test_django_objecttype_fields(): class Reporter(DjangoObjectType): class Meta: model = ReporterModel - exclude_fields = "email" + fields = ("id", "email", "films") + + fields = list(Reporter._meta.fields.keys()) + assert fields == ["id", "email", "films"] + + +@with_local_registry +def test_django_objecttype_only_fields_and_fields(): + with pytest.raises(Exception): + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + only_fields = ("id", "email", "films") + fields = ("id", "email", "films") + + +@with_local_registry +def test_django_objecttype_all_fields(): + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = "__all__" + + fields = list(Reporter._meta.fields.keys()) + assert len(fields) == len(ReporterModel._meta.get_fields()) + + +@with_local_registry +def test_django_objecttype_exclude_fields(): + with pytest.warns(PendingDeprecationWarning): + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + exclude_fields = ["email"] fields = list(Reporter._meta.fields.keys()) assert "email" not in fields +@with_local_registry +def test_django_objecttype_exclude(): + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + exclude = ["email"] + + fields = list(Reporter._meta.fields.keys()) + assert "email" not in fields + + +@with_local_registry +def test_django_objecttype_exclude_fields_and_exclude(): + with pytest.raises(Exception): + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + exclude = ["email"] + exclude_fields = ["email"] + + +@with_local_registry +def test_django_objecttype_exclude_and_only(): + with pytest.raises(AssertionError): + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + exclude = ["email"] + fields = ["id"] + + +@with_local_registry +def test_django_objecttype_fields_exclude_type_checking(): + with pytest.raises(TypeError): + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = "foo" + + with pytest.raises(TypeError): + + class Reporter2(DjangoObjectType): + class Meta: + model = ReporterModel + fields = "foo" + + class TestDjangoObjectType: @pytest.fixture def PetModel(self): diff --git a/graphene_django/types.py b/graphene_django/types.py index 6c100ef..ec426f1 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -1,3 +1,4 @@ +import warnings from collections import OrderedDict import six @@ -24,6 +25,9 @@ if six.PY3: from typing import Type +ALL_FIELDS = "__all__" + + def construct_fields( model, registry, only_fields, exclude_fields, convert_choices_to_enum ): @@ -74,8 +78,10 @@ class DjangoObjectType(ObjectType): model=None, registry=None, skip_registry=False, - only_fields=(), - exclude_fields=(), + only_fields=(), # deprecated in favour of `fields` + fields=(), + exclude_fields=(), # deprecated in favour of `exclude` + exclude=(), filter_fields=None, filterset_class=None, connection=None, @@ -109,10 +115,48 @@ class DjangoObjectType(ObjectType): ) ) + assert not (fields and exclude), ( + "Cannot set both 'fields' and 'exclude' options on " + "DjangoObjectType {class_name}.".format(class_name=cls.__name__) + ) + + # Alias only_fields -> fields + if only_fields and fields: + raise Exception("Can't set both only_fields and fields") + if only_fields: + warnings.warn( + "Defining `only_fields` is deprecated in favour of `fields`.", + PendingDeprecationWarning, + stacklevel=2, + ) + fields = only_fields + if fields and fields != ALL_FIELDS and not isinstance(fields, (list, tuple)): + raise TypeError( + 'The `fields` option must be a list or tuple or "__all__". ' + "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") + if exclude_fields: + warnings.warn( + "Defining `exclude_fields` is deprecated in favour of `exclude`.", + PendingDeprecationWarning, + stacklevel=2, + ) + exclude = exclude_fields + if exclude and not isinstance(exclude, (list, tuple)): + raise TypeError( + "The `exclude` option must be a list or tuple. Got %s." + % type(exclude).__name__ + ) + django_fields = yank_fields_from_attrs( - construct_fields( - model, registry, only_fields, exclude_fields, convert_choices_to_enum - ), + construct_fields(model, registry, fields, exclude, convert_choices_to_enum), _as=Field, ) From 224725039bb15373890d49329bb588104ab275cd Mon Sep 17 00:00:00 2001 From: Semyon Pupkov Date: Thu, 11 Jul 2019 22:32:07 +0300 Subject: [PATCH 17/28] =?UTF-8?q?Asserting=20status=20code=20before=20deco?= =?UTF-8?q?ding=20json=20in=20assertResponseNoEr=E2=80=A6=20(#708)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- 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 db3e9f4..0fdac7e 100644 --- a/graphene_django/utils/testing.py +++ b/graphene_django/utils/testing.py @@ -54,8 +54,8 @@ class GraphQLTestCase(TestCase): the call was fine. :resp HttpResponse: Response """ - content = json.loads(resp.content) self.assertEqual(resp.status_code, 200) + content = json.loads(resp.content) self.assertNotIn("errors", list(content.keys())) def assertResponseHasErrors(self, resp): From de98fb58121ec5c7126800ef59896d4e2fc23702 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Fri, 12 Jul 2019 17:38:26 +0100 Subject: [PATCH 18/28] v2.4.0 (#706) --- 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 51acfd2..e09f2a2 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.3.0" +__version__ = "2.4.0" __all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] From 51adb3632bab8ce0f200cde0686a158436f07ab3 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sat, 27 Jul 2019 16:14:34 +0200 Subject: [PATCH 19/28] Update readme with Django path (#720) --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 159a592..33f71f3 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,12 @@ GRAPHENE = { We need to set up a `GraphQL` endpoint in our Django app, so we can serve the queries. ```python -from django.conf.urls import url +from django.urls import path from graphene_django.views import GraphQLView urlpatterns = [ # ... - url(r'^graphql$', GraphQLView.as_view(graphiql=True)), + path('graphql', GraphQLView.as_view(graphiql=True)), ] ``` @@ -100,4 +100,4 @@ To learn more check out the following [examples](examples/): ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) \ No newline at end of file +See [CONTRIBUTING.md](CONTRIBUTING.md) From b1a9293016a5263efe9ed39b1f6db2dac0b9623a Mon Sep 17 00:00:00 2001 From: Jason Kraus Date: Thu, 1 Aug 2019 01:07:52 -0700 Subject: [PATCH 20/28] fix choices enum: if field can be blank then it isnt required (#714) --- graphene_django/converter.py | 3 ++- graphene_django/tests/models.py | 2 +- graphene_django/tests/test_types.py | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 64bf341..b1e27fc 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -73,7 +73,8 @@ def convert_django_field_with_choices( return named_choices_descriptions[self.name] enum = Enum(name, list(named_choices), type=EnumWithDescriptionsType) - converted = enum(description=field.help_text, required=not field.null) + required = not (field.blank or field.null) + converted = enum(description=field.help_text, required=required) else: converted = convert_django_field(field, registry) if registry is not None: diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py index b4eb3ce..14a8367 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -38,7 +38,7 @@ class Reporter(models.Model): last_name = models.CharField(max_length=30) email = models.EmailField() pets = models.ManyToManyField("self") - a_choice = models.CharField(max_length=30, choices=CHOICES) + a_choice = models.CharField(max_length=30, choices=CHOICES, blank=True) objects = models.Manager() doe_objects = DoeReporterManager() diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 6cbaae0..8b84fca 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -171,7 +171,7 @@ type Reporter { lastName: String! email: String! pets: [Reporter!]! - aChoice: ReporterAChoice! + aChoice: ReporterAChoice reporterType: ReporterReporterType articles(before: String, after: String, first: Int, last: Int): ArticleConnection } From 59f4f134b584d54e3accc5c8f1abeaca8b17a003 Mon Sep 17 00:00:00 2001 From: Alexandre Kirszenberg Date: Thu, 1 Aug 2019 18:31:18 +0200 Subject: [PATCH 21/28] Set converted Django connections to required (#610) --- graphene_django/converter.py | 6 ++++-- graphene_django/filter/fields.py | 2 +- graphene_django/tests/test_converter.py | 2 +- graphene_django/tests/test_types.py | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/graphene_django/converter.py b/graphene_django/converter.py index b1e27fc..063d6be 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -195,9 +195,11 @@ def convert_field_to_list_or_connection(field, registry=None): if _type._meta.filter_fields or _type._meta.filterset_class: from .filter.fields import DjangoFilterConnectionField - return DjangoFilterConnectionField(_type, description=description) + return DjangoFilterConnectionField( + _type, required=True, description=description + ) - return DjangoConnectionField(_type, description=description) + return DjangoConnectionField(_type, required=True, description=description) return DjangoListField( _type, diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py index 62f4b1a..338becb 100644 --- a/graphene_django/filter/fields.py +++ b/graphene_django/filter/fields.py @@ -111,7 +111,7 @@ class DjangoFilterConnectionField(DjangoConnectionField): return partial( self.connection_resolver, parent_resolver, - self.type, + self.connection_type, self.get_manager(), self.max_limit, self.enforce_first_or_last, diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index 00467b4..3790c4a 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -255,7 +255,7 @@ def test_should_manytomany_convert_connectionorlist_connection(): assert isinstance(graphene_field, graphene.Dynamic) dynamic_field = graphene_field.get_type() assert isinstance(dynamic_field, ConnectionField) - assert dynamic_field.type == A._meta.connection + assert dynamic_field.type.of_type == A._meta.connection def test_should_manytoone_convert_connectionorlist(): diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 8b84fca..5e9d1c2 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -173,7 +173,7 @@ type Reporter { pets: [Reporter!]! aChoice: ReporterAChoice reporterType: ReporterReporterType - articles(before: String, after: String, first: Int, last: Int): ArticleConnection + articles(before: String, after: String, first: Int, last: Int): ArticleConnection! } enum ReporterAChoice { From 6e137da4695c5d23ade330e3dc21be7c49a8b601 Mon Sep 17 00:00:00 2001 From: Kike Isidoro Date: Wed, 7 Aug 2019 09:04:04 +0200 Subject: [PATCH 22/28] Check for filters defined on base filterset classes (#730) * Check for filters defined on base filterset classes * Make python2.7 compatible and run black * Add filter method and use filter in test * Check article headline and reformat --- graphene_django/filter/tests/test_fields.py | 103 ++++++++++++++++++++ graphene_django/filter/utils.py | 22 +++-- 2 files changed, 116 insertions(+), 9 deletions(-) diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index 99876b6..aa6a903 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -818,3 +818,106 @@ def test_integer_field_filter_type(): } """ ) + + +def test_filter_filterset_based_on_mixin(): + class ArticleFilterMixin(FilterSet): + @classmethod + def get_filters(cls): + filters = super(FilterSet, cls).get_filters() + filters.update( + { + "viewer__email__in": django_filters.CharFilter( + method="filter_email_in", field_name="reporter__email__in" + ) + } + ) + + return filters + + def filter_email_in(cls, queryset, name, value): + return queryset.filter(**{name: [value]}) + + class NewArticleFilter(ArticleFilterMixin, ArticleFilter): + pass + + class NewReporterNode(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + + class NewArticleFilterNode(DjangoObjectType): + viewer = Field(NewReporterNode) + + class Meta: + model = Article + interfaces = (Node,) + filterset_class = NewArticleFilter + + def resolve_viewer(self, info): + return self.reporter + + class Query(ObjectType): + all_articles = DjangoFilterConnectionField(NewArticleFilterNode) + + reporter_1 = Reporter.objects.create( + first_name="John", last_name="Doe", email="john@doe.com" + ) + + article_1 = Article.objects.create( + headline="Hello", + reporter=reporter_1, + editor=reporter_1, + pub_date=datetime.now(), + pub_date_time=datetime.now(), + ) + + reporter_2 = Reporter.objects.create( + first_name="Adam", last_name="Doe", email="adam@doe.com" + ) + + article_2 = Article.objects.create( + headline="Good Bye", + reporter=reporter_2, + editor=reporter_2, + pub_date=datetime.now(), + pub_date_time=datetime.now(), + ) + + schema = Schema(query=Query) + + query = ( + """ + query NodeFilteringQuery { + allArticles(viewer_Email_In: "%s") { + edges { + node { + headline + viewer { + email + } + } + } + } + } + """ + % reporter_1.email + ) + + expected = { + "allArticles": { + "edges": [ + { + "node": { + "headline": article_1.headline, + "viewer": {"email": reporter_1.email}, + } + } + ] + } + } + + result = schema.execute(query) + + assert not result.errors + assert result.data == expected diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index 00030a0..81efb63 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -13,21 +13,25 @@ def get_filtering_args_from_filterset(filterset_class, type): args = {} model = filterset_class._meta.model for name, filter_field in six.iteritems(filterset_class.base_filters): + form_field = None + if name in filterset_class.declared_filters: form_field = filter_field.field else: field_name = name.split("__", 1)[0] - model_field = model._meta.get_field(field_name) - if hasattr(model_field, "formfield"): - form_field = model_field.formfield( - required=filter_field.extra.get("required", False) - ) + if hasattr(model, field_name): + model_field = model._meta.get_field(field_name) - # Fallback to field defined on filter if we can't get it from the - # model field - if not form_field: - form_field = filter_field.field + if hasattr(model_field, "formfield"): + form_field = model_field.formfield( + required=filter_field.extra.get("required", False) + ) + + # Fallback to field defined on filter if we can't get it from the + # model field + if not form_field: + form_field = filter_field.field field_type = convert_form_field(form_field).Argument() field_type.description = filter_field.label From 11605dcdc6806cbd9d466e84a5e50e0958a438f0 Mon Sep 17 00:00:00 2001 From: Tomasz Kontusz Date: Wed, 7 Aug 2019 09:09:17 +0200 Subject: [PATCH 23/28] Make DjangoDebugContext wait for nested fields (#591) * Make DjangoDebugContext wait for nested fields This commit makes DjangoDebugContext wait for all field's promises, even for fields that only started their resolvers after __debug was resolved. Fixes #293. * Run format --- graphene_django/debug/middleware.py | 6 +- graphene_django/debug/tests/test_query.py | 67 +++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/graphene_django/debug/middleware.py b/graphene_django/debug/middleware.py index 48d471f..0fe3fe3 100644 --- a/graphene_django/debug/middleware.py +++ b/graphene_django/debug/middleware.py @@ -16,14 +16,18 @@ class DjangoDebugContext(object): def get_debug_promise(self): if not self.debug_promise: self.debug_promise = Promise.all(self.promises) + self.promises = [] return self.debug_promise.then(self.on_resolve_all_promises) def on_resolve_all_promises(self, values): + if self.promises: + self.debug_promise = None + return self.get_debug_promise() self.disable_instrumentation() return self.object def add_promise(self, promise): - if self.debug_promise and not self.debug_promise.is_fulfilled: + if self.debug_promise: self.promises.append(promise) def enable_instrumentation(self): diff --git a/graphene_django/debug/tests/test_query.py b/graphene_django/debug/tests/test_query.py index af69715..db8f275 100644 --- a/graphene_django/debug/tests/test_query.py +++ b/graphene_django/debug/tests/test_query.py @@ -60,6 +60,73 @@ def test_should_query_field(): assert result.data == expected +def test_should_query_nested_field(): + r1 = Reporter(last_name="ABA") + r1.save() + r2 = Reporter(last_name="Griffin") + r2.save() + r2.pets.add(r1) + r1.pets.add(r2) + + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + + class Query(graphene.ObjectType): + reporter = graphene.Field(ReporterType) + debug = graphene.Field(DjangoDebug, name="__debug") + + def resolve_reporter(self, info, **args): + return Reporter.objects.first() + + query = """ + query ReporterQuery { + reporter { + lastName + pets { edges { node { + lastName + pets { edges { node { lastName } } } + } } } + } + __debug { + sql { + rawSql + } + } + } + """ + expected = { + "reporter": { + "lastName": "ABA", + "pets": { + "edges": [ + { + "node": { + "lastName": "Griffin", + "pets": {"edges": [{"node": {"lastName": "ABA"}}]}, + } + } + ] + }, + } + } + schema = graphene.Schema(query=Query) + result = schema.execute( + query, context_value=context(), middleware=[DjangoDebugMiddleware()] + ) + 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 + + assert result.data["reporter"] == expected["reporter"] + + def test_should_query_list(): r1 = Reporter(last_name="ABA") r1.save() From c432d5875b244a07f929bf5daa68b591e3ec7360 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Aug 2019 08:09:42 +0100 Subject: [PATCH 24/28] Bump django from 2.2.3 to 2.2.4 in /examples/cookbook (#734) Bumps [django](https://github.com/django/django) from 2.2.3 to 2.2.4. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.2.3...2.2.4) Signed-off-by: dependabot[bot] --- 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 49470ed..0537103 100644 --- a/examples/cookbook/requirements.txt +++ b/examples/cookbook/requirements.txt @@ -1,5 +1,5 @@ graphene graphene-django graphql-core>=2.1rc1 -django==2.2.3 +django==2.2.4 django-filter>=2 From 930adb50ce3d418357f09ab5131a2a6f305e09ca Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 7 Aug 2019 08:09:56 +0100 Subject: [PATCH 25/28] Bump django from 2.1.10 to 2.1.11 in /examples/cookbook-plain (#733) Bumps [django](https://github.com/django/django) from 2.1.10 to 2.1.11. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.1.10...2.1.11) Signed-off-by: dependabot[bot] --- 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 1dc8fcd..8b8f675 100644 --- a/examples/cookbook-plain/requirements.txt +++ b/examples/cookbook-plain/requirements.txt @@ -1,4 +1,4 @@ graphene graphene-django graphql-core>=2.1rc1 -django==2.1.10 +django==2.1.11 From 87aebdb6300e62a22243573af6994a9077f239e4 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sat, 10 Aug 2019 11:55:42 +0100 Subject: [PATCH 26/28] v2.5.0 (#739) --- 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 e09f2a2..659cc79 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.4.0" +__version__ = "2.5.0" __all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] From a04fff9d70e06d427a06990c30bd6141401fa29c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 10 Aug 2019 18:50:39 +0100 Subject: [PATCH 27/28] Bump django from 2.1.11 to 2.2.4 in /examples/cookbook-plain (#736) Bumps [django](https://github.com/django/django) from 2.1.11 to 2.2.4. - [Release notes](https://github.com/django/django/releases) - [Commits](https://github.com/django/django/compare/2.1.11...2.2.4) Signed-off-by: dependabot[bot] --- 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 8b8f675..802aa37 100644 --- a/examples/cookbook-plain/requirements.txt +++ b/examples/cookbook-plain/requirements.txt @@ -1,4 +1,4 @@ graphene graphene-django graphql-core>=2.1rc1 -django==2.1.11 +django==2.2.4 From d5e71bc9be5c11cd4261419629f5ea54d6eae42b Mon Sep 17 00:00:00 2001 From: Gert Van Gool Date: Sat, 10 Aug 2019 22:30:17 +0200 Subject: [PATCH 28/28] Fix typo of imoprt to import (#742) --- docs/mutations.rst | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/mutations.rst b/docs/mutations.rst index 6610151..362df58 100644 --- a/docs/mutations.rst +++ b/docs/mutations.rst @@ -151,7 +151,7 @@ customize the look up with the ``lookup_field`` attribute on the ``SerializerMut .. code:: python from graphene_django.rest_framework.mutation import SerializerMutation - from .serializers imoprt MyModelSerializer + from .serializers import MyModelSerializer class AwesomeModelMutation(SerializerMutation): @@ -168,7 +168,7 @@ Use the method ``get_serializer_kwargs`` to override how updates are applied. .. code:: python from graphene_django.rest_framework.mutation import SerializerMutation - from .serializers imoprt MyModelSerializer + from .serializers import MyModelSerializer class AwesomeModelMutation(SerializerMutation): @@ -199,7 +199,7 @@ You can use relay with mutations. A Relay mutation must inherit from .. code:: python - import graphene + import graphene from graphene import relay from graphene_django import DjangoObjectType from graphql_relay import from_global_id