diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 139c6f6..5d5ae27 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -20,7 +20,7 @@ jobs:
pip install wheel
python setup.py sdist bdist_wheel
- name: Publish a Python distribution to PyPI
- uses: pypa/gh-action-pypi-publish@v1.8.6
+ uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.pypi_password }}
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index bfafa67..920ecf0 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -1,6 +1,9 @@
name: Lint
-on: [push, pull_request]
+on:
+ push:
+ branches: ["main"]
+ pull_request:
jobs:
build:
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 2c5b755..17876a2 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -1,6 +1,9 @@
name: Tests
-on: [push, pull_request]
+on:
+ push:
+ branches: ["main"]
+ pull_request:
jobs:
build:
@@ -8,13 +11,13 @@ jobs:
strategy:
max-parallel: 4
matrix:
- django: ["3.2", "4.0", "4.1"]
+ django: ["3.2", "4.1", "4.2"]
python-version: ["3.8", "3.9", "3.10"]
include:
- - django: "3.2"
- python-version: "3.7"
- django: "4.1"
python-version: "3.11"
+ - django: "4.2"
+ python-version: "3.11"
steps:
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
@@ -29,4 +32,3 @@ jobs:
run: tox
env:
DJANGO: ${{ matrix.django }}
- TOXENV: ${{ matrix.toxenv }}
diff --git a/.gitignore b/.gitignore
index 150025a..3cf0d9a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,9 @@ __pycache__/
# Distribution / packaging
.Python
env/
+.env/
+venv/
+.venv/
build/
develop-eggs/
dist/
@@ -80,3 +83,8 @@ Session.vim
tags
.tox/
.pytest_cache/
+
+# pyenv
+# For a library or package, you might want to ignore these files since the code is
+# intended to run in multiple environments; otherwise, check them in:
+.python-version
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 9214d35..5174be3 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -15,16 +15,12 @@ repos:
- --autofix
- id: trailing-whitespace
exclude: README.md
-- repo: https://github.com/asottile/pyupgrade
- rev: v3.3.2
- hooks:
- - id: pyupgrade
- args: [--py37-plus]
- repo: https://github.com/psf/black
- rev: 23.3.0
+ rev: 23.7.0
hooks:
- id: black
-- repo: https://github.com/PyCQA/flake8
- rev: 6.0.0
+- repo: https://github.com/astral-sh/ruff-pre-commit
+ rev: v0.0.283
hooks:
- - id: flake8
+ - id: ruff
+ args: [--fix, --exit-non-zero-on-fix, --show-fixes]
diff --git a/.ruff.toml b/.ruff.toml
new file mode 100644
index 0000000..b24997c
--- /dev/null
+++ b/.ruff.toml
@@ -0,0 +1,33 @@
+select = [
+ "E", # pycodestyle
+ "W", # pycodestyle
+ "F", # pyflake
+ "I", # isort
+ "B", # flake8-bugbear
+ "C4", # flake8-comprehensions
+ "UP", # pyupgrade
+]
+
+ignore = [
+ "E501", # line-too-long
+ "B017", # pytest.raises(Exception) should be considered evil
+ "B028", # warnings.warn called without an explicit stacklevel keyword argument
+ "B904", # check for raise statements in exception handlers that lack a from clause
+]
+
+exclude = [
+ "**/docs",
+]
+
+target-version = "py38"
+
+[per-file-ignores]
+# Ignore unused imports (F401) in these files
+"__init__.py" = ["F401"]
+"graphene_django/compat.py" = ["F401"]
+
+[isort]
+known-first-party = ["graphene", "graphene-django"]
+known-local-folder = ["cookbook"]
+force-wrap-aliases = true
+combine-as-imports = true
diff --git a/Makefile b/Makefile
index 29c412b..31e5c93 100644
--- a/Makefile
+++ b/Makefile
@@ -10,7 +10,7 @@ dev-setup:
.PHONY: tests ## Run unit tests
tests:
- py.test graphene_django --cov=graphene_django -vv
+ PYTHONPATH=. pytest graphene_django --cov=graphene_django -vv
.PHONY: format ## Format code
format:
@@ -18,7 +18,7 @@ format:
.PHONY: lint ## Lint code
lint:
- flake8 graphene_django examples
+ ruff graphene_django examples
.PHONY: docs ## Generate docs
docs: dev-setup
diff --git a/docs/authorization.rst b/docs/authorization.rst
index bc88cda..8595def 100644
--- a/docs/authorization.rst
+++ b/docs/authorization.rst
@@ -144,6 +144,21 @@ If you are using ``DjangoObjectType`` you can define a custom `get_queryset`.
return queryset.filter(published=True)
return queryset
+.. warning::
+
+ Defining a custom ``get_queryset`` gives the guaranteed it will be called
+ when resolving the ``DjangoObjectType``, even through related objects.
+ Note that because of this, benefits from using ``select_related``
+ in objects that define a relation to this ``DjangoObjectType`` will be canceled out.
+ In the case of ``prefetch_related``, the benefits of the optimization will be lost only
+ if the custom ``get_queryset`` modifies the queryset. For more information about this, refers
+ to Django documentation about ``prefetch_related``: https://docs.djangoproject.com/en/4.2/ref/models/querysets/#prefetch-related.
+
+
+ If you want to explicitly disable the execution of the custom ``get_queryset`` when resolving,
+ you can decorate the resolver with `@graphene_django.bypass_get_queryset`. Note that this
+ can lead to authorization leaks if you are performing authorization checks in the custom
+ ``get_queryset``.
Filtering ID-based Node Access
------------------------------
@@ -197,8 +212,8 @@ For Django 2.2 and above:
.. code:: python
urlpatterns = [
- # some other urls
- path('graphql/', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
+ # some other urls
+ path('graphql/', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
]
.. _LoginRequiredMixin: https://docs.djangoproject.com/en/dev/topics/auth/default/#the-loginrequired-mixin
diff --git a/docs/conf.py b/docs/conf.py
index b83e0f0..1be08b1 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -78,7 +78,7 @@ release = "1.0.dev"
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
-language = None
+# language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
@@ -445,4 +445,7 @@ epub_exclude_files = ["search.html"]
# Example configuration for intersphinx: refer to the Python standard library.
-intersphinx_mapping = {"https://docs.python.org/": None}
+intersphinx_mapping = {
+ # "https://docs.python.org/": None,
+ "python": ("https://docs.python.org/", None),
+}
diff --git a/docs/introspection.rst b/docs/introspection.rst
index 2097c30..a4ecaae 100644
--- a/docs/introspection.rst
+++ b/docs/introspection.rst
@@ -57,9 +57,9 @@ specify the parameters in your settings.py:
.. code:: python
GRAPHENE = {
- 'SCHEMA': 'tutorial.quickstart.schema',
- 'SCHEMA_OUTPUT': 'data/schema.json', # defaults to schema.json,
- 'SCHEMA_INDENT': 2, # Defaults to None (displays all data on a single line)
+ 'SCHEMA': 'tutorial.quickstart.schema',
+ 'SCHEMA_OUTPUT': 'data/schema.json', # defaults to schema.json,
+ 'SCHEMA_INDENT': 2, # Defaults to None (displays all data on a single line)
}
diff --git a/docs/mutations.rst b/docs/mutations.rst
index 2ce8f16..f43063f 100644
--- a/docs/mutations.rst
+++ b/docs/mutations.rst
@@ -125,6 +125,55 @@ to change how the form is saved or to return a different Graphene object type.
If the form is *not* valid then a list of errors will be returned. These errors have two fields: ``field``, a string
containing the name of the invalid form field, and ``messages``, a list of strings with the validation messages.
+DjangoFormInputObjectType
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+``DjangoFormInputObjectType`` is used in mutations to create input fields by **using django form** to retrieve input data structure from it. This can be helpful in situations where you need to pass data to several django forms in one mutation.
+
+.. code:: python
+
+ from graphene_django.forms.types import DjangoFormInputObjectType
+
+
+ class PetFormInput(DjangoFormInputObjectType):
+ # any other fields can be placed here as well as
+ # other djangoforminputobjects and intputobjects
+ class Meta:
+ form_class = PetForm
+ object_type = PetType
+
+ class QuestionFormInput(DjangoFormInputObjectType)
+ class Meta:
+ form_class = QuestionForm
+ object_type = QuestionType
+
+ class SeveralFormsInputData(graphene.InputObjectType):
+ pet = PetFormInput(required=True)
+ question = QuestionFormInput(required=True)
+
+ class SomeSophisticatedMutation(graphene.Mutation):
+ class Arguments:
+ data = SeveralFormsInputData(required=True)
+
+ @staticmethod
+ def mutate(_root, _info, data):
+ pet_form_inst = PetForm(data=data.pet)
+ question_form_inst = QuestionForm(data=data.question)
+
+ if pet_form_inst.is_valid():
+ pet_model_instance = pet_form_inst.save(commit=False)
+
+ if question_form_inst.is_valid():
+ question_model_instance = question_form_inst.save(commit=False)
+
+ # ...
+
+Additional to **InputObjectType** ``Meta`` class attributes:
+
+* ``form_class`` is required and should be equal to django form class.
+* ``object_type`` is not required and used to enable convertion of enum values back to original if model object type ``convert_choices_to_enum`` ``Meta`` class attribute is not set to ``False``. Any data field, which have choices in django, with value ``A_1`` (for example) from client will be automatically converted to ``1`` in mutation data.
+* ``add_id_field_name`` is used to specify `id` field name (not required, by default equal to ``id``)
+* ``add_id_field_type`` is used to specify `id` field type (not required, default is ``graphene.ID``)
Django REST Framework
---------------------
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 7c89926..276ae37 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,4 +1,5 @@
-Sphinx==1.5.3
-sphinx-autobuild==0.7.1
+Sphinx==7.0.0
+sphinx-autobuild==2021.3.14
+pygments-graphql-lexer==0.1.0
# Docs template
http://graphene-python.org/sphinx_graphene_theme.zip
diff --git a/docs/settings.rst b/docs/settings.rst
index 5bffd08..d38d0c9 100644
--- a/docs/settings.rst
+++ b/docs/settings.rst
@@ -224,7 +224,7 @@ Default: ``/graphql``
``GRAPHIQL_SHOULD_PERSIST_HEADERS``
----------------------
+-----------------------------------
Set to ``True`` if you want to persist GraphiQL headers after refreshing the page.
diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst
index a27e255..bf93299 100644
--- a/docs/tutorial-relay.rst
+++ b/docs/tutorial-relay.rst
@@ -12,7 +12,7 @@ app `__
-* `GraphQL Relay Specification `__
+* `GraphQL Relay Specification `__
Setup the Django project
------------------------
diff --git a/examples/cookbook-plain/README.md b/examples/cookbook-plain/README.md
index dcd2420..b120b78 100644
--- a/examples/cookbook-plain/README.md
+++ b/examples/cookbook-plain/README.md
@@ -62,3 +62,12 @@ Now head on over to
and run some queries!
(See the [Graphene-Django Tutorial](http://docs.graphene-python.org/projects/django/en/latest/tutorial-plain/#testing-our-graphql-schema)
for some example queries)
+
+Testing local graphene-django changes
+-------------------------------------
+
+In `requirements.txt`, replace the entire `graphene-django=...` line with the following (so that we install the local version instead of the one from PyPI):
+
+```
+../../ # graphene-django
+```
diff --git a/examples/cookbook-plain/cookbook/schema.py b/examples/cookbook-plain/cookbook/schema.py
index bde9372..8c4e5e4 100644
--- a/examples/cookbook-plain/cookbook/schema.py
+++ b/examples/cookbook-plain/cookbook/schema.py
@@ -1,8 +1,8 @@
+import graphene
+from graphene_django.debug import DjangoDebug
+
import cookbook.ingredients.schema
import cookbook.recipes.schema
-import graphene
-
-from graphene_django.debug import DjangoDebug
class Query(
diff --git a/examples/cookbook-plain/cookbook/settings.py b/examples/cookbook-plain/cookbook/settings.py
index 7eb9d56..f07f6f6 100644
--- a/examples/cookbook-plain/cookbook/settings.py
+++ b/examples/cookbook-plain/cookbook/settings.py
@@ -5,10 +5,10 @@ Django settings for cookbook project.
Generated by 'django-admin startproject' using Django 1.9.
For more information on this file, see
-https://docs.djangoproject.com/en/1.9/topics/settings/
+https://docs.djangoproject.com/en/3.2/topics/settings/
For the full list of settings and their values, see
-https://docs.djangoproject.com/en/1.9/ref/settings/
+https://docs.djangoproject.com/en/3.2/ref/settings/
"""
import os
@@ -18,7 +18,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Quick-start development settings - unsuitable for production
-# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/
+# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "_$=$%eqxk$8ss4n7mtgarw^5$8^d5+c83!vwatr@i_81myb=e4"
@@ -81,7 +81,7 @@ WSGI_APPLICATION = "cookbook.wsgi.application"
# Database
-# https://docs.djangoproject.com/en/1.9/ref/settings/#databases
+# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
DATABASES = {
"default": {
@@ -90,9 +90,11 @@ DATABASES = {
}
}
+# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
+DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Password validation
-# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators
+# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
@@ -105,7 +107,7 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization
-# https://docs.djangoproject.com/en/1.9/topics/i18n/
+# https://docs.djangoproject.com/en/3.2/topics/i18n/
LANGUAGE_CODE = "en-us"
@@ -119,6 +121,6 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
-# https://docs.djangoproject.com/en/1.9/howto/static-files/
+# https://docs.djangoproject.com/en/3.2/howto/static-files/
STATIC_URL = "/static/"
diff --git a/examples/cookbook-plain/cookbook/urls.py b/examples/cookbook-plain/cookbook/urls.py
index a64a875..a5ec5de 100644
--- a/examples/cookbook-plain/cookbook/urls.py
+++ b/examples/cookbook-plain/cookbook/urls.py
@@ -1,9 +1,8 @@
-from django.urls import path
from django.contrib import admin
+from django.urls import path
from graphene_django.views import GraphQLView
-
urlpatterns = [
path("admin/", admin.site.urls),
path("graphql/", GraphQLView.as_view(graphiql=True)),
diff --git a/examples/cookbook-plain/requirements.txt b/examples/cookbook-plain/requirements.txt
index 85a8963..6e02745 100644
--- a/examples/cookbook-plain/requirements.txt
+++ b/examples/cookbook-plain/requirements.txt
@@ -1,4 +1,3 @@
-graphene>=2.1,<3
-graphene-django>=2.1,<3
-graphql-core>=2.1,<3
-django==3.1.14
+django~=3.2
+graphene
+graphene-django>=3.1
diff --git a/examples/cookbook/cookbook/ingredients/schema.py b/examples/cookbook/cookbook/ingredients/schema.py
index 4ed9eff..941f379 100644
--- a/examples/cookbook/cookbook/ingredients/schema.py
+++ b/examples/cookbook/cookbook/ingredients/schema.py
@@ -1,8 +1,9 @@
-from cookbook.ingredients.models import Category, Ingredient
from graphene import Node
from graphene_django.filter import DjangoFilterConnectionField
from graphene_django.types import DjangoObjectType
+from cookbook.ingredients.models import Category, Ingredient
+
# Graphene will automatically map the Category model's fields onto the CategoryNode.
# This is configured in the CategoryNode's Meta class (as you can see below)
diff --git a/examples/cookbook/cookbook/recipes/models.py b/examples/cookbook/cookbook/recipes/models.py
index 0bfb434..03da594 100644
--- a/examples/cookbook/cookbook/recipes/models.py
+++ b/examples/cookbook/cookbook/recipes/models.py
@@ -6,7 +6,9 @@ from cookbook.ingredients.models import Ingredient
class Recipe(models.Model):
title = models.CharField(max_length=100)
instructions = models.TextField()
- __unicode__ = lambda self: self.title
+
+ def __unicode__(self):
+ return self.title
class RecipeIngredient(models.Model):
diff --git a/examples/cookbook/cookbook/recipes/schema.py b/examples/cookbook/cookbook/recipes/schema.py
index 82f7f1d..19fbcc6 100644
--- a/examples/cookbook/cookbook/recipes/schema.py
+++ b/examples/cookbook/cookbook/recipes/schema.py
@@ -7,6 +7,8 @@ from graphene import Node, String, Field
from graphene_django.filter import DjangoFilterConnectionField
from graphene_django.types import DjangoObjectType
+from cookbook.recipes.models import Recipe, RecipeIngredient
+
class RecipeNode(DjangoObjectType):
async_field = String()
diff --git a/examples/cookbook/cookbook/schema.py b/examples/cookbook/cookbook/schema.py
index bde9372..8c4e5e4 100644
--- a/examples/cookbook/cookbook/schema.py
+++ b/examples/cookbook/cookbook/schema.py
@@ -1,8 +1,8 @@
+import graphene
+from graphene_django.debug import DjangoDebug
+
import cookbook.ingredients.schema
import cookbook.recipes.schema
-import graphene
-
-from graphene_django.debug import DjangoDebug
class Query(
diff --git a/examples/cookbook/cookbook/urls.py b/examples/cookbook/cookbook/urls.py
index 541cd2d..57687da 100644
--- a/examples/cookbook/cookbook/urls.py
+++ b/examples/cookbook/cookbook/urls.py
@@ -2,6 +2,7 @@ from django.urls import re_path
from django.contrib import admin
from django.views.decorators.csrf import csrf_exempt
from graphene_django.views import AsyncGraphQLView
+from graphene_django.views import GraphQLView
urlpatterns = [
re_path(r"^admin/", admin.site.urls),
diff --git a/examples/django_test_settings.py b/examples/django_test_settings.py
index 7b98861..dcb1f6c 100644
--- a/examples/django_test_settings.py
+++ b/examples/django_test_settings.py
@@ -1,5 +1,5 @@
-import sys
import os
+import sys
ROOT_PATH = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, ROOT_PATH + "/examples/")
diff --git a/examples/starwars/schema.py b/examples/starwars/schema.py
index 4bc26e9..07bf9d2 100644
--- a/examples/starwars/schema.py
+++ b/examples/starwars/schema.py
@@ -3,9 +3,11 @@ from graphene import Schema, relay, resolve_only_args
from graphene_django import DjangoConnectionField, DjangoObjectType
from .data import create_ship, get_empire, get_faction, get_rebels, get_ship, get_ships
-from .models import Character as CharacterModel
-from .models import Faction as FactionModel
-from .models import Ship as ShipModel
+from .models import (
+ Character as CharacterModel,
+ Faction as FactionModel,
+ Ship as ShipModel,
+)
class Ship(DjangoObjectType):
diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py
index 12408a4..22a035d 100644
--- a/graphene_django/__init__.py
+++ b/graphene_django/__init__.py
@@ -1,11 +1,13 @@
from .fields import DjangoConnectionField, DjangoListField
from .types import DjangoObjectType
+from .utils import bypass_get_queryset
-__version__ = "3.0.2"
+__version__ = "3.1.5"
__all__ = [
"__version__",
"DjangoObjectType",
"DjangoListField",
"DjangoConnectionField",
+ "bypass_get_queryset",
]
diff --git a/graphene_django/compat.py b/graphene_django/compat.py
index b0e4753..fde632a 100644
--- a/graphene_django/compat.py
+++ b/graphene_django/compat.py
@@ -1,3 +1,9 @@
+# For backwards compatibility, we import JSONField to have it available for import via
+# this compat module (https://github.com/graphql-python/graphene-django/issues/1428).
+# Django's JSONField is available in Django 3.2+ (the minimum version we support)
+from django.db.models import JSONField
+
+
class MissingType:
def __init__(self, *args, **kwargs):
pass
@@ -7,19 +13,10 @@ try:
# Postgres fields are only available in Django with psycopg2 installed
# and we cannot have psycopg2 on PyPy
from django.contrib.postgres.fields import (
- IntegerRangeField,
ArrayField,
HStoreField,
- JSONField as PGJSONField,
+ IntegerRangeField,
RangeField,
)
except ImportError:
- IntegerRangeField, ArrayField, HStoreField, PGJSONField, RangeField = (
- MissingType,
- ) * 5
-
-try:
- # JSONField is only available from Django 3.1
- from django.db.models import JSONField
-except ImportError:
- JSONField = MissingType
+ IntegerRangeField, ArrayField, HStoreField, RangeField = (MissingType,) * 4
diff --git a/graphene_django/converter.py b/graphene_django/converter.py
index 375d683..f4775e8 100644
--- a/graphene_django/converter.py
+++ b/graphene_django/converter.py
@@ -1,10 +1,12 @@
+import inspect
from collections import OrderedDict
-from functools import singledispatch, wraps
+from functools import partial, singledispatch, wraps
from django.db import models
from django.utils.encoding import force_str
from django.utils.functional import Promise
from django.utils.module_loading import import_string
+from graphql import GraphQLError
from graphene import (
ID,
@@ -12,6 +14,7 @@ from graphene import (
Boolean,
Date,
DateTime,
+ Decimal,
Dynamic,
Enum,
Field,
@@ -21,12 +24,11 @@ from graphene import (
NonNull,
String,
Time,
- Decimal,
)
from graphene.types.json import JSONString
+from graphene.types.resolver import get_default_resolver
from graphene.types.scalars import BigInt
from graphene.utils.str_converters import to_camel_case
-from graphql import GraphQLError
try:
from graphql import assert_name
@@ -35,8 +37,8 @@ except ImportError:
from graphql import assert_valid_name as assert_name
from graphql.pyutils import register_description
-from .compat import ArrayField, HStoreField, JSONField, PGJSONField, RangeField
-from .fields import DjangoListField, DjangoConnectionField
+from .compat import ArrayField, HStoreField, RangeField
+from .fields import DjangoConnectionField, DjangoListField
from .settings import graphene_settings
from .utils.str_converters import to_const
@@ -159,9 +161,7 @@ def get_django_field_description(field):
@singledispatch
def convert_django_field(field, registry=None):
raise Exception(
- "Don't know how to convert the Django field {} ({})".format(
- field, field.__class__
- )
+ f"Don't know how to convert the Django field {field} ({field.__class__})"
)
@@ -258,6 +258,10 @@ def convert_time_to_string(field, registry=None):
@convert_django_field.register(models.OneToOneRel)
def convert_onetoone_field_to_djangomodel(field, registry=None):
+ from graphene.utils.str_converters import to_snake_case
+
+ from .types import DjangoObjectType
+
model = field.related_model
def dynamic_type():
@@ -265,7 +269,55 @@ def convert_onetoone_field_to_djangomodel(field, registry=None):
if not _type:
return
- return Field(_type, required=not field.null)
+ class CustomField(Field):
+ def wrap_resolve(self, parent_resolver):
+ """
+ Implements a custom resolver which goes through the `get_node` method to ensure that
+ it goes through the `get_queryset` method of the DjangoObjectType.
+ """
+ resolver = super().wrap_resolve(parent_resolver)
+
+ # If `get_queryset` was not overridden in the DjangoObjectType
+ # or if we explicitly bypass the `get_queryset` method,
+ # we can just return the default resolver.
+ if (
+ _type.get_queryset.__func__
+ is DjangoObjectType.get_queryset.__func__
+ or getattr(resolver, "_bypass_get_queryset", False)
+ ):
+ return resolver
+
+ def custom_resolver(root, info, **args):
+ # Note: this function is used to resolve 1:1 relation fields
+
+ is_resolver_awaitable = inspect.iscoroutinefunction(resolver)
+
+ if is_resolver_awaitable:
+ fk_obj = resolver(root, info, **args)
+ # In case the resolver is a custom awaitable resolver that overwrites
+ # the default Django resolver
+ return fk_obj
+
+ field_name = to_snake_case(info.field_name)
+ reversed_field_name = root.__class__._meta.get_field(
+ field_name
+ ).remote_field.name
+ try:
+ return _type.get_queryset(
+ _type._meta.model.objects.filter(
+ **{reversed_field_name: root.pk}
+ ),
+ info,
+ ).get()
+ except _type._meta.model.DoesNotExist:
+ return None
+
+ return custom_resolver
+
+ return CustomField(
+ _type,
+ required=not field.null,
+ )
return Dynamic(dynamic_type)
@@ -313,6 +365,10 @@ def convert_field_to_list_or_connection(field, registry=None):
@convert_django_field.register(models.OneToOneField)
@convert_django_field.register(models.ForeignKey)
def convert_field_to_djangomodel(field, registry=None):
+ from graphene.utils.str_converters import to_snake_case
+
+ from .types import DjangoObjectType
+
model = field.related_model
def dynamic_type():
@@ -320,7 +376,79 @@ def convert_field_to_djangomodel(field, registry=None):
if not _type:
return
- return Field(
+ class CustomField(Field):
+ def wrap_resolve(self, parent_resolver):
+ """
+ Implements a custom resolver which goes through the `get_node` method to ensure that
+ it goes through the `get_queryset` method of the DjangoObjectType.
+ """
+ resolver = super().wrap_resolve(parent_resolver)
+
+ # If `get_queryset` was not overridden in the DjangoObjectType
+ # or if we explicitly bypass the `get_queryset` method,
+ # we can just return the default resolver.
+ if (
+ _type.get_queryset.__func__
+ is DjangoObjectType.get_queryset.__func__
+ or getattr(resolver, "_bypass_get_queryset", False)
+ ):
+ return resolver
+
+ def custom_resolver(root, info, **args):
+ # Note: this function is used to resolve FK or 1:1 fields
+ # it does not differentiate between custom-resolved fields
+ # and default resolved fields.
+
+ # because this is a django foreign key or one-to-one field, the primary-key for
+ # this node can be accessed from the root node.
+ # ex: article.reporter_id
+
+ # get the name of the id field from the root's model
+ field_name = to_snake_case(info.field_name)
+ db_field_key = root.__class__._meta.get_field(field_name).attname
+ if hasattr(root, db_field_key):
+ # get the object's primary-key from root
+ object_pk = getattr(root, db_field_key)
+ else:
+ return None
+
+ is_resolver_awaitable = inspect.iscoroutinefunction(resolver)
+
+ if is_resolver_awaitable:
+ fk_obj = resolver(root, info, **args)
+ # In case the resolver is a custom awaitable resolver that overwrites
+ # the default Django resolver
+ return fk_obj
+
+ instance_from_get_node = _type.get_node(info, object_pk)
+
+ if instance_from_get_node is None:
+ # no instance to return
+ return
+ elif (
+ isinstance(resolver, partial)
+ and resolver.func is get_default_resolver()
+ ):
+ return instance_from_get_node
+ elif resolver is not get_default_resolver():
+ # Default resolver is overridden
+ # For optimization, add the instance to the resolver
+ setattr(root, field_name, instance_from_get_node)
+ # Explanation:
+ # previously, _type.get_node` is called which results in at least one hit to the database.
+ # But, if we did not pass the instance to the root, calling the resolver will result in
+ # another call to get the instance which results in at least two database queries in total
+ # to resolve this node only.
+ # That's why the value of the object is set in the root so when the object is accessed
+ # in the resolver (root.field_name) it does not access the database unless queried explicitly.
+ fk_obj = resolver(root, info, **args)
+ return fk_obj
+ else:
+ return instance_from_get_node
+
+ return custom_resolver
+
+ return CustomField(
_type,
description=get_django_field_description(field),
required=not field.null,
@@ -346,9 +474,8 @@ 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_pg_and_json_field_to_string(field, registry=None):
+@convert_django_field.register(models.JSONField)
+def convert_json_field_to_string(field, registry=None):
return JSONString(
description=get_django_field_description(field), required=not field.null
)
diff --git a/graphene_django/debug/tests/test_query.py b/graphene_django/debug/tests/test_query.py
index 1ea86b1..1f5e584 100644
--- a/graphene_django/debug/tests/test_query.py
+++ b/graphene_django/debug/tests/test_query.py
@@ -1,5 +1,6 @@
-import graphene
import pytest
+
+import graphene
from graphene.relay import Node
from graphene_django import DjangoConnectionField, DjangoObjectType
diff --git a/graphene_django/debug/types.py b/graphene_django/debug/types.py
index a523b4f..4b0f9d1 100644
--- a/graphene_django/debug/types.py
+++ b/graphene_django/debug/types.py
@@ -1,7 +1,7 @@
from graphene import List, ObjectType
-from .sql.types import DjangoDebugSQL
from .exception.types import DjangoDebugException
+from .sql.types import DjangoDebugSQL
class DjangoDebug(ObjectType):
diff --git a/graphene_django/fields.py b/graphene_django/fields.py
index f58082b..50a80dc 100644
--- a/graphene_django/fields.py
+++ b/graphene_django/fields.py
@@ -2,7 +2,6 @@ import inspect
from functools import partial
from django.db.models.query import QuerySet
-
from graphql_relay import (
connection_from_array_slice,
cursor_to_offset,
@@ -11,6 +10,7 @@ from graphql_relay import (
)
from asgiref.sync import sync_to_async
+from promise import Promise
from graphene import Int, NonNull
from graphene.relay import ConnectionField
diff --git a/graphene_django/filter/__init__.py b/graphene_django/filter/__init__.py
index f02fc6b..e4dbc06 100644
--- a/graphene_django/filter/__init__.py
+++ b/graphene_django/filter/__init__.py
@@ -1,4 +1,5 @@
import warnings
+
from ..utils import DJANGO_FILTER_INSTALLED
if not DJANGO_FILTER_INSTALLED:
diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py
index a4ae821..4c8d826 100644
--- a/graphene_django/filter/fields.py
+++ b/graphene_django/filter/fields.py
@@ -3,8 +3,8 @@ from functools import partial
from django.core.exceptions import ValidationError
-from graphene.types.enum import EnumType
from graphene.types.argument import to_arguments
+from graphene.types.enum import EnumType
from graphene.utils.str_converters import to_snake_case
from asgiref.sync import sync_to_async
@@ -60,7 +60,7 @@ class DjangoFilterConnectionField(DjangoConnectionField):
def filterset_class(self):
if not self._filterset_class:
fields = self._fields or self.node_type._meta.filter_fields
- meta = dict(model=self.model, fields=fields)
+ meta = {"model": self.model, "fields": fields}
if self._extra_filter_meta:
meta.update(self._extra_filter_meta)
diff --git a/graphene_django/filter/filters/__init__.py b/graphene_django/filter/filters/__init__.py
index fcf75af..a81a96c 100644
--- a/graphene_django/filter/filters/__init__.py
+++ b/graphene_django/filter/filters/__init__.py
@@ -1,4 +1,5 @@
import warnings
+
from ...utils import DJANGO_FILTER_INSTALLED
if not DJANGO_FILTER_INSTALLED:
diff --git a/graphene_django/filter/filters/global_id_filter.py b/graphene_django/filter/filters/global_id_filter.py
index 37877d5..e0de1e3 100644
--- a/graphene_django/filter/filters/global_id_filter.py
+++ b/graphene_django/filter/filters/global_id_filter.py
@@ -1,5 +1,4 @@
from django_filters import Filter, MultipleChoiceFilter
-
from graphql_relay.node.node import from_global_id
from ...forms import GlobalIDFormField, GlobalIDMultipleChoiceField
diff --git a/graphene_django/filter/filterset.py b/graphene_django/filter/filterset.py
index fa91477..7e0d0c5 100644
--- a/graphene_django/filter/filterset.py
+++ b/graphene_django/filter/filterset.py
@@ -1,12 +1,14 @@
import itertools
from django.db import models
-from django_filters.filterset import BaseFilterSet, FilterSet
-from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS
+from django_filters.filterset import (
+ FILTER_FOR_DBFIELD_DEFAULTS,
+ BaseFilterSet,
+ FilterSet,
+)
from .filters import GlobalIDFilter, GlobalIDMultipleChoiceFilter
-
GRAPHENE_FILTER_SET_OVERRIDES = {
models.AutoField: {"filter_class": GlobalIDFilter},
models.OneToOneField: {"filter_class": GlobalIDFilter},
diff --git a/graphene_django/filter/tests/conftest.py b/graphene_django/filter/tests/conftest.py
index f8a65d7..1556f54 100644
--- a/graphene_django/filter/tests/conftest.py
+++ b/graphene_django/filter/tests/conftest.py
@@ -1,15 +1,15 @@
from unittest.mock import MagicMock
-import pytest
+import pytest
from django.db import models
from django.db.models.query import QuerySet
-from django_filters import filters
from django_filters import FilterSet
+
import graphene
from graphene.relay import Node
from graphene_django import DjangoObjectType
+from graphene_django.filter import ArrayFilter
from graphene_django.utils import DJANGO_FILTER_INSTALLED
-from graphene_django.filter import ArrayFilter, ListFilter
from ...compat import ArrayField
diff --git a/graphene_django/filter/tests/test_enum_filtering.py b/graphene_django/filter/tests/test_enum_filtering.py
index a284d08..32238e5 100644
--- a/graphene_django/filter/tests/test_enum_filtering.py
+++ b/graphene_django/filter/tests/test_enum_filtering.py
@@ -2,8 +2,7 @@ import pytest
import graphene
from graphene.relay import Node
-
-from graphene_django import DjangoObjectType, DjangoConnectionField
+from graphene_django import DjangoConnectionField, DjangoObjectType
from graphene_django.tests.models import Article, Reporter
from graphene_django.utils import DJANGO_FILTER_INSTALLED
diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py
index bee3c6c..df3b97a 100644
--- a/graphene_django/filter/tests/test_fields.py
+++ b/graphene_django/filter/tests/test_fields.py
@@ -19,8 +19,8 @@ if DJANGO_FILTER_INSTALLED:
from django_filters import FilterSet, NumberFilter, OrderingFilter
from graphene_django.filter import (
- GlobalIDFilter,
DjangoFilterConnectionField,
+ GlobalIDFilter,
GlobalIDMultipleChoiceFilter,
)
from graphene_django.filter.tests.filters import (
@@ -222,7 +222,7 @@ def test_filter_filterset_information_on_meta_related():
reporter = Field(ReporterFilterNode)
article = Field(ArticleFilterNode)
- schema = Schema(query=Query)
+ Schema(query=Query)
articles_field = ReporterFilterNode._meta.fields["articles"].get_type()
assert_arguments(articles_field, "headline", "reporter")
assert_not_orderable(articles_field)
@@ -294,7 +294,7 @@ def test_filter_filterset_class_information_on_meta_related():
reporter = Field(ReporterFilterNode)
article = Field(ArticleFilterNode)
- schema = Schema(query=Query)
+ Schema(query=Query)
articles_field = ReporterFilterNode._meta.fields["articles"].get_type()
assert_arguments(articles_field, "headline", "reporter")
assert_not_orderable(articles_field)
@@ -1186,7 +1186,7 @@ def test_filter_filterset_based_on_mixin():
first_name="Adam", last_name="Doe", email="adam@doe.com"
)
- article_2 = Article.objects.create(
+ Article.objects.create(
headline="Good Bye",
reporter=reporter_2,
editor=reporter_2,
diff --git a/graphene_django/filter/tests/test_in_filter.py b/graphene_django/filter/tests/test_in_filter.py
index a69d6f5..b91475d 100644
--- a/graphene_django/filter/tests/test_in_filter.py
+++ b/graphene_django/filter/tests/test_in_filter.py
@@ -1,14 +1,16 @@
from datetime import datetime
import pytest
+from django_filters import (
+ FilterSet,
+ rest_framework as filters,
+)
-from django_filters import FilterSet
-from django_filters import rest_framework as filters
from graphene import ObjectType, Schema
from graphene.relay import Node
from graphene_django import DjangoObjectType
-from graphene_django.tests.models import Pet, Person, Reporter, Article, Film
from graphene_django.filter.tests.filters import ArticleFilter
+from graphene_django.tests.models import Article, Film, Person, Pet, Reporter
from graphene_django.utils import DJANGO_FILTER_INSTALLED
pytestmark = []
@@ -348,9 +350,9 @@ def test_fk_id_in_filter(query):
schema = Schema(query=query)
- query = """
+ query = f"""
query {{
- articles (reporter_In: [{}, {}]) {{
+ articles (reporter_In: [{john_doe.id}, {jean_bon.id}]) {{
edges {{
node {{
headline
@@ -361,10 +363,7 @@ def test_fk_id_in_filter(query):
}}
}}
}}
- """.format(
- john_doe.id,
- jean_bon.id,
- )
+ """
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
diff --git a/graphene_django/filter/tests/test_range_filter.py b/graphene_django/filter/tests/test_range_filter.py
index 6227a70..e08660c 100644
--- a/graphene_django/filter/tests/test_range_filter.py
+++ b/graphene_django/filter/tests/test_range_filter.py
@@ -1,8 +1,7 @@
import json
+
import pytest
-from django_filters import FilterSet
-from django_filters import rest_framework as filters
from graphene import ObjectType, Schema
from graphene.relay import Node
from graphene_django import DjangoObjectType
diff --git a/graphene_django/filter/tests/test_typed_filter.py b/graphene_django/filter/tests/test_typed_filter.py
index f22138f..084affa 100644
--- a/graphene_django/filter/tests/test_typed_filter.py
+++ b/graphene_django/filter/tests/test_typed_filter.py
@@ -1,10 +1,8 @@
import pytest
-
from django_filters import FilterSet
import graphene
from graphene.relay import Node
-
from graphene_django import DjangoObjectType
from graphene_django.tests.models import Article, Reporter
from graphene_django.utils import DJANGO_FILTER_INSTALLED
@@ -14,8 +12,8 @@ pytestmark = []
if DJANGO_FILTER_INSTALLED:
from graphene_django.filter import (
DjangoFilterConnectionField,
- TypedFilter,
ListFilter,
+ TypedFilter,
)
else:
pytestmark.append(
diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py
index ebd2a00..3dd835f 100644
--- a/graphene_django/filter/utils.py
+++ b/graphene_django/filter/utils.py
@@ -1,10 +1,11 @@
-import graphene
from django import forms
-from django_filters.utils import get_model_field, get_field_parts
-from django_filters.filters import Filter, BaseCSVFilter
-from .filters import ArrayFilter, ListFilter, RangeFilter, TypedFilter
-from .filterset import custom_filterset_factory, setup_filterset
+from django_filters.utils import get_model_field
+
+import graphene
+
from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField
+from .filters import ListFilter, RangeFilter, TypedFilter
+from .filterset import custom_filterset_factory, setup_filterset
def get_field_type(registry, model, field_name):
@@ -50,7 +51,7 @@ def get_filtering_args_from_filterset(filterset_class, type):
):
# Get the filter field for filters that are no explicitly declared.
if filter_type == "isnull":
- field = graphene.Boolean(required=required)
+ field_type = graphene.Boolean
else:
model_field = get_model_field(model, filter_field.field_name)
diff --git a/graphene_django/forms/converter.py b/graphene_django/forms/converter.py
index 47eb51d..60996b4 100644
--- a/graphene_django/forms/converter.py
+++ b/graphene_django/forms/converter.py
@@ -5,15 +5,15 @@ from django.core.exceptions import ImproperlyConfigured
from graphene import (
ID,
+ UUID,
Boolean,
+ Date,
+ DateTime,
Decimal,
Float,
Int,
List,
String,
- UUID,
- Date,
- DateTime,
Time,
)
@@ -27,8 +27,8 @@ def get_form_field_description(field):
@singledispatch
def convert_form_field(field):
raise ImproperlyConfigured(
- "Don't know how to convert the Django form field %s (%s) "
- "to Graphene type" % (field, field.__class__)
+ f"Don't know how to convert the Django form field {field} ({field.__class__}) "
+ "to Graphene type"
)
diff --git a/graphene_django/forms/forms.py b/graphene_django/forms/forms.py
index 4b81859..f6ed031 100644
--- a/graphene_django/forms/forms.py
+++ b/graphene_django/forms/forms.py
@@ -3,7 +3,6 @@ import binascii
from django.core.exceptions import ValidationError
from django.forms import CharField, Field, MultipleChoiceField
from django.utils.translation import gettext_lazy as _
-
from graphql_relay import from_global_id
diff --git a/graphene_django/forms/tests/test_converter.py b/graphene_django/forms/tests/test_converter.py
index b61227b..7e2a6d3 100644
--- a/graphene_django/forms/tests/test_converter.py
+++ b/graphene_django/forms/tests/test_converter.py
@@ -1,19 +1,18 @@
from django import forms
from pytest import raises
-import graphene
from graphene import (
- String,
- Int,
- Boolean,
- Decimal,
- Float,
ID,
UUID,
+ Boolean,
+ Date,
+ DateTime,
+ Decimal,
+ Float,
+ Int,
List,
NonNull,
- DateTime,
- Date,
+ String,
Time,
)
diff --git a/graphene_django/forms/tests/test_djangoinputobject.py b/graphene_django/forms/tests/test_djangoinputobject.py
new file mode 100644
index 0000000..20b816e
--- /dev/null
+++ b/graphene_django/forms/tests/test_djangoinputobject.py
@@ -0,0 +1,333 @@
+from django import forms
+from pytest import raises
+
+import graphene
+from graphene_django import DjangoObjectType
+
+from ...tests.models import CHOICES, Film, Reporter
+from ..types import DjangoFormInputObjectType
+
+# Reporter a_choice CHOICES = ((1, "this"), (2, _("that")))
+THIS = CHOICES[0][0]
+THIS_ON_CLIENT_CONVERTED = "A_1"
+
+# Film genre choices=[("do", "Documentary"), ("ac", "Action"), ("ot", "Other")],
+DOCUMENTARY = "do"
+DOCUMENTARY_ON_CLIENT_CONVERTED = "DO"
+
+
+class FilmForm(forms.ModelForm):
+ class Meta:
+ model = Film
+ exclude = ()
+
+
+class ReporterType(DjangoObjectType):
+ class Meta:
+ model = Reporter
+ fields = "__all__"
+
+
+class ReporterForm(forms.ModelForm):
+ class Meta:
+ model = Reporter
+ exclude = ("pets", "email", "fans")
+
+
+class MyForm(forms.Form):
+ text_field = forms.CharField()
+ int_field = forms.IntegerField()
+
+
+def test_needs_form_class():
+ with raises(Exception) as exc:
+
+ class MyInputType(DjangoFormInputObjectType):
+ pass
+
+ assert exc.value.args[0] == "form_class is required for DjangoFormInputObjectType"
+
+
+def test_type_from_modelform_has_input_fields():
+ class ReporterInputType(DjangoFormInputObjectType):
+ class Meta:
+ form_class = ReporterForm
+ only_fields = ("first_name", "last_name", "a_choice")
+
+ fields = ["first_name", "last_name", "a_choice", "id"]
+ assert all(f in ReporterInputType._meta.fields for f in fields)
+
+
+def test_type_from_form_has_input_fields():
+ class MyFormInputType(DjangoFormInputObjectType):
+ class Meta:
+ form_class = MyForm
+
+ fields = ["text_field", "int_field", "id"]
+ assert all(f in MyFormInputType._meta.fields for f in fields)
+
+
+def test_type_custom_id_field():
+ class MyFormInputType(DjangoFormInputObjectType):
+ class Meta:
+ form_class = MyForm
+ add_id_field_name = "pk"
+
+ fields = ["text_field", "int_field", "pk"]
+ assert all(f in MyFormInputType._meta.fields for f in fields)
+ assert MyFormInputType._meta.fields["pk"].type is graphene.ID
+
+
+def test_type_custom_id_field_type():
+ class MyFormInputType(DjangoFormInputObjectType):
+ class Meta:
+ form_class = MyForm
+ add_id_field_name = "pk"
+ add_id_field_type = graphene.String(required=False)
+
+ fields = ["text_field", "int_field", "pk"]
+ assert all(f in MyFormInputType._meta.fields for f in fields)
+ assert MyFormInputType._meta.fields["pk"].type is graphene.String
+
+
+class MockQuery(graphene.ObjectType):
+ a = graphene.String()
+
+
+def test_mutation_with_form_djangoforminputtype():
+ class MyFormInputType(DjangoFormInputObjectType):
+ class Meta:
+ form_class = MyForm
+
+ class MyFormMutation(graphene.Mutation):
+ class Arguments:
+ form_data = MyFormInputType(required=True)
+
+ result = graphene.Boolean()
+
+ def mutate(_root, _info, form_data):
+ form = MyForm(data=form_data)
+ if form.is_valid():
+ result = form.cleaned_data == {
+ "text_field": "text",
+ "int_field": 777,
+ }
+ return MyFormMutation(result=result)
+ return MyFormMutation(result=False)
+
+ class Mutation(graphene.ObjectType):
+ myForm_mutation = MyFormMutation.Field()
+
+ schema = graphene.Schema(query=MockQuery, mutation=Mutation)
+
+ result = schema.execute(
+ """ mutation MyFormMutation($formData: MyFormInputType!) {
+ myFormMutation(formData: $formData) {
+ result
+ }
+ }
+ """,
+ variable_values={"formData": {"textField": "text", "intField": 777}},
+ )
+ assert result.errors is None
+ assert result.data == {"myFormMutation": {"result": True}}
+
+
+def test_mutation_with_modelform_djangoforminputtype():
+ class ReporterInputType(DjangoFormInputObjectType):
+ class Meta:
+ form_class = ReporterForm
+ object_type = ReporterType
+ only_fields = ("first_name", "last_name", "a_choice")
+
+ class ReporterMutation(graphene.Mutation):
+ class Arguments:
+ reporter_data = ReporterInputType(required=True)
+
+ result = graphene.Field(ReporterType)
+
+ def mutate(_root, _info, reporter_data):
+ reporter = Reporter.objects.get(pk=reporter_data.id)
+ form = ReporterForm(data=reporter_data, instance=reporter)
+ if form.is_valid():
+ reporter = form.save()
+ return ReporterMutation(result=reporter)
+
+ return ReporterMutation(result=None)
+
+ class Mutation(graphene.ObjectType):
+ report_mutation = ReporterMutation.Field()
+
+ schema = graphene.Schema(query=MockQuery, mutation=Mutation)
+
+ reporter = Reporter.objects.create(
+ first_name="Bob", last_name="Roberts", a_choice=THIS
+ )
+
+ result = schema.execute(
+ """ mutation ReportMutation($reporterData: ReporterInputType!) {
+ reportMutation(reporterData: $reporterData) {
+ result {
+ id,
+ firstName,
+ lastName,
+ aChoice
+ }
+ }
+ }
+ """,
+ variable_values={
+ "reporterData": {
+ "id": reporter.pk,
+ "firstName": "Dave",
+ "lastName": "Smith",
+ "aChoice": THIS_ON_CLIENT_CONVERTED,
+ }
+ },
+ )
+ assert result.errors is None
+ assert result.data["reportMutation"]["result"] == {
+ "id": "1",
+ "firstName": "Dave",
+ "lastName": "Smith",
+ "aChoice": THIS_ON_CLIENT_CONVERTED,
+ }
+ assert Reporter.objects.count() == 1
+ reporter.refresh_from_db()
+ assert reporter.first_name == "Dave"
+
+
+def reporter_enum_convert_mutation_result(
+ ReporterInputType, choice_val_on_client=THIS_ON_CLIENT_CONVERTED
+):
+ class ReporterMutation(graphene.Mutation):
+ class Arguments:
+ reporter = ReporterInputType(required=True)
+
+ result_str = graphene.String()
+ result_int = graphene.Int()
+
+ def mutate(_root, _info, reporter):
+ if isinstance(reporter.a_choice, int) or reporter.a_choice.isdigit():
+ return ReporterMutation(result_int=reporter.a_choice, result_str=None)
+ return ReporterMutation(result_int=None, result_str=reporter.a_choice)
+
+ class Mutation(graphene.ObjectType):
+ report_mutation = ReporterMutation.Field()
+
+ schema = graphene.Schema(query=MockQuery, mutation=Mutation)
+
+ return schema.execute(
+ """ mutation ReportMutation($reporter: ReporterInputType!) {
+ reportMutation(reporter: $reporter) {
+ resultStr,
+ resultInt
+ }
+ }
+ """,
+ variable_values={"reporter": {"aChoice": choice_val_on_client}},
+ )
+
+
+def test_enum_not_converted():
+ class ReporterInputType(DjangoFormInputObjectType):
+ class Meta:
+ form_class = ReporterForm
+ only_fields = ("a_choice",)
+
+ result = reporter_enum_convert_mutation_result(ReporterInputType)
+ assert result.errors is None
+ assert result.data["reportMutation"]["resultStr"] == THIS_ON_CLIENT_CONVERTED
+ assert result.data["reportMutation"]["resultInt"] is None
+ assert ReporterInputType._meta.fields["a_choice"].type is graphene.String
+
+
+def test_enum_is_converted_to_original():
+ class ReporterInputType(DjangoFormInputObjectType):
+ class Meta:
+ form_class = ReporterForm
+ object_type = ReporterType
+ only_fields = ("a_choice",)
+
+ result = reporter_enum_convert_mutation_result(ReporterInputType)
+ assert result.errors is None
+ assert result.data["reportMutation"]["resultInt"] == THIS
+ assert result.data["reportMutation"]["resultStr"] is None
+ assert (
+ ReporterInputType._meta.fields["a_choice"].type.__name__
+ == "AChoiceEnumBackConvString"
+ )
+
+
+def test_convert_choices_to_enum_is_false_and_field_type_as_in_model():
+ class ReporterTypeNotConvertChoices(DjangoObjectType):
+ class Meta:
+ model = Reporter
+ convert_choices_to_enum = False
+ fields = "__all__"
+
+ class ReporterInputType(DjangoFormInputObjectType):
+ class Meta:
+ form_class = ReporterForm
+ object_type = ReporterTypeNotConvertChoices
+ only_fields = ("a_choice",)
+
+ result = reporter_enum_convert_mutation_result(ReporterInputType, THIS)
+ assert result.errors is None
+ assert result.data["reportMutation"]["resultInt"] == THIS
+ assert result.data["reportMutation"]["resultStr"] is None
+ assert ReporterInputType._meta.fields["a_choice"].type is graphene.Int
+
+
+def enum_convert_mutation_result_film(FilmInputType):
+ class FilmMutation(graphene.Mutation):
+ class Arguments:
+ film = FilmInputType(required=True)
+
+ result = graphene.String()
+
+ def mutate(_root, _info, film):
+ return FilmMutation(result=film.genre)
+
+ class Mutation(graphene.ObjectType):
+ film_mutation = FilmMutation.Field()
+
+ schema = graphene.Schema(query=MockQuery, mutation=Mutation)
+
+ return schema.execute(
+ """ mutation FilmMutation($film: FilmInputType!) {
+ filmMutation(film: $film) {
+ result
+ }
+ }
+ """,
+ variable_values={"film": {"genre": DOCUMENTARY_ON_CLIENT_CONVERTED}},
+ )
+
+
+def test_enum_not_converted_required_non_number():
+ class FilmInputType(DjangoFormInputObjectType):
+ class Meta:
+ form_class = FilmForm
+ only_fields = ("genre",)
+
+ result = enum_convert_mutation_result_film(FilmInputType)
+ assert result.errors is None
+ assert result.data["filmMutation"]["result"] == DOCUMENTARY_ON_CLIENT_CONVERTED
+
+
+def test_enum_is_converted_to_original_required_non_number():
+ class FilmType(DjangoObjectType):
+ class Meta:
+ model = Film
+ fields = "__all__"
+
+ class FilmInputType(DjangoFormInputObjectType):
+ class Meta:
+ form_class = FilmForm
+ object_type = FilmType
+ only_fields = ("genre",)
+
+ result = enum_convert_mutation_result_film(FilmInputType)
+ assert result.errors is None
+ assert result.data["filmMutation"]["result"] == DOCUMENTARY
diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py
index 14c407c..230b2fd 100644
--- a/graphene_django/forms/tests/test_mutation.py
+++ b/graphene_django/forms/tests/test_mutation.py
@@ -1,4 +1,3 @@
-import pytest
from django import forms
from django.core.exceptions import ValidationError
from pytest import raises
@@ -280,7 +279,7 @@ def test_model_form_mutation_mutate_invalid_form():
result = PetMutation.mutate_and_get_payload(None, None)
# A pet was not created
- Pet.objects.count() == 0
+ assert Pet.objects.count() == 0
fields_w_error = [e.field for e in result.errors]
assert len(result.errors) == 2
diff --git a/graphene_django/forms/types.py b/graphene_django/forms/types.py
index 74e275e..b370afd 100644
--- a/graphene_django/forms/types.py
+++ b/graphene_django/forms/types.py
@@ -1 +1,117 @@
+import graphene
+from graphene import ID
+from graphene.types.inputobjecttype import InputObjectType
+from graphene.utils.str_converters import to_camel_case
+
+from ..converter import BlankValueField
from ..types import ErrorType # noqa Import ErrorType for backwards compatability
+from .mutation import fields_for_form
+
+
+class DjangoFormInputObjectType(InputObjectType):
+ @classmethod
+ def __init_subclass_with_meta__(
+ cls,
+ container=None,
+ _meta=None,
+ only_fields=(),
+ exclude_fields=(),
+ form_class=None,
+ object_type=None,
+ add_id_field_name=None,
+ add_id_field_type=None,
+ **options,
+ ):
+ """Retrieve fields from django form (Meta.form_class). Received
+ fields are set to cls (they will be converted to input fields
+ by InputObjectType). Type of fields with choices (converted
+ to enum) is set to custom scalar type (using Meta.object_type)
+ to dynamically convert enum values back.
+
+ class MyDjangoFormInput(DjangoFormInputObjectType):
+ # any other fields can be placed here and other inputobjectforms as well
+
+ class Meta:
+ form_class = MyDjangoModelForm
+ object_type = MyModelType
+
+ class SomeMutation(graphene.Mutation):
+ class Arguments:
+ data = MyDjangoFormInput(required=True)
+
+ @staticmethod
+ def mutate(_root, _info, data):
+ form_inst = MyDjangoModelForm(data=data)
+ if form_inst.is_valid():
+ django_model_instance = form_inst.save(commit=False)
+ # ... etc ...
+ """
+
+ if not form_class:
+ raise Exception("form_class is required for DjangoFormInputObjectType")
+
+ form = form_class()
+ form_fields = fields_for_form(form, only_fields, exclude_fields)
+
+ for name, field in form_fields.items():
+ if (
+ object_type
+ and name in object_type._meta.fields
+ and isinstance(object_type._meta.fields[name], BlankValueField)
+ ):
+ # Field type BlankValueField here means that field
+ # with choises have been converted to enum
+ # (BlankValueField is using only for that task ?)
+ setattr(cls, name, cls.get_enum_cnv_cls_instance(name, object_type))
+ elif (
+ object_type
+ and name in object_type._meta.fields
+ and object_type._meta.convert_choices_to_enum is False
+ and form.fields[name].__class__.__name__ == "TypedChoiceField"
+ ):
+ # FIXME
+ # in case if convert_choices_to_enum is False
+ # form field class is converted to String but original
+ # model field type is needed here... (.converter.py bug?)
+ # This is temp workaround to get field type from ObjectType field
+ # TEST: test_enum_not_converted_and_field_type_as_in_model
+ setattr(cls, name, object_type._meta.fields[name].type())
+ else:
+ # set input field according to django form field
+ setattr(cls, name, field)
+
+ # explicitly adding id field (absent in django form fields)
+ # with name and type from Meta or 'id' with graphene.ID by default
+ if add_id_field_name:
+ setattr(cls, add_id_field_name, add_id_field_type or ID(required=False))
+ elif "id" not in exclude_fields:
+ cls.id = ID(required=False)
+
+ super().__init_subclass_with_meta__(container=container, _meta=_meta, **options)
+
+ @staticmethod
+ def get_enum_cnv_cls_instance(field_name, object_type):
+ """Saves args in context to convert enum values in
+ Dynamically created Scalar derived class
+ """
+
+ @staticmethod
+ def parse_value(value):
+ # field_name & object_type have been saved in context (closure)
+ field = object_type._meta.fields[field_name]
+ if isinstance(field.type, graphene.NonNull):
+ val_before_convert = field.type._of_type[value].value
+ else:
+ val_before_convert = field.type[value].value
+ return graphene.String.parse_value(val_before_convert)
+
+ cls_doc = "String scalar to convert choice value back from enum to original"
+ scalar_type = type(
+ (
+ f"{field_name[0].upper()}{to_camel_case(field_name[1:])}"
+ "EnumBackConvString"
+ ),
+ (graphene.String,),
+ {"parse_value": parse_value, "__doc__": cls_doc},
+ )
+ return scalar_type()
diff --git a/graphene_django/management/commands/graphql_schema.py b/graphene_django/management/commands/graphql_schema.py
index 42c41c1..25972b8 100644
--- a/graphene_django/management/commands/graphql_schema.py
+++ b/graphene_django/management/commands/graphql_schema.py
@@ -1,12 +1,12 @@
-import os
+import functools
import importlib
import json
-import functools
+import os
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
@@ -83,7 +83,7 @@ class Command(CommandArguments):
def handle(self, *args, **options):
options_schema = options.get("schema")
- if options_schema and type(options_schema) is str:
+ if options_schema and isinstance(options_schema, str):
module_str, schema_name = options_schema.rsplit(".", 1)
mod = importlib.import_module(module_str)
schema = getattr(mod, schema_name)
diff --git a/graphene_django/registry.py b/graphene_django/registry.py
index 4708637..900feeb 100644
--- a/graphene_django/registry.py
+++ b/graphene_django/registry.py
@@ -8,9 +8,7 @@ class Registry:
assert issubclass(
cls, DjangoObjectType
- ), 'Only DjangoObjectTypes can be registered, received "{}"'.format(
- cls.__name__
- )
+ ), f'Only DjangoObjectTypes can be registered, received "{cls.__name__}"'
assert cls._meta.registry == self, "Registry for a Model have to match."
# assert self.get_type_for_model(cls._meta.model) == cls, (
# 'Multiple DjangoObjectTypes registered for "{}"'.format(cls._meta.model)
diff --git a/graphene_django/rest_framework/models.py b/graphene_django/rest_framework/models.py
index bd84ce5..d31c3eb 100644
--- a/graphene_django/rest_framework/models.py
+++ b/graphene_django/rest_framework/models.py
@@ -14,3 +14,14 @@ class MyFakeModelWithPassword(models.Model):
class MyFakeModelWithDate(models.Model):
cool_name = models.CharField(max_length=50)
last_edited = models.DateField()
+
+
+class MyFakeModelWithChoiceField(models.Model):
+ class ChoiceType(models.Choices):
+ ASDF = "asdf"
+ HI = "hi"
+
+ choice_type = models.CharField(
+ max_length=4,
+ default=ChoiceType.HI.name,
+ )
diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py
index ca2764d..1057299 100644
--- a/graphene_django/rest_framework/mutation.py
+++ b/graphene_django/rest_framework/mutation.py
@@ -1,4 +1,5 @@
from collections import OrderedDict
+from enum import Enum
from django.shortcuts import get_object_or_404
from rest_framework import serializers
@@ -41,6 +42,9 @@ def fields_for_serializer(
field.read_only
and is_input
and lookup_field != name, # don't show read_only fields in Input
+ isinstance(
+ field, serializers.HiddenField
+ ), # don't show hidden fields in Input
]
)
@@ -123,8 +127,10 @@ class SerializerMutation(ClientIDMutation):
def get_serializer_kwargs(cls, root, info, **input):
lookup_field = cls._meta.lookup_field
model_class = cls._meta.model_class
-
if model_class:
+ for input_dict_key, maybe_enum in input.items():
+ if isinstance(maybe_enum, Enum):
+ input[input_dict_key] = maybe_enum.value
if "update" in cls._meta.model_operations and lookup_field in input:
instance = get_object_or_404(
model_class, **{lookup_field: input[lookup_field]}
diff --git a/graphene_django/rest_framework/serializer_converter.py b/graphene_django/rest_framework/serializer_converter.py
index 1d850f0..328c46f 100644
--- a/graphene_django/rest_framework/serializer_converter.py
+++ b/graphene_django/rest_framework/serializer_converter.py
@@ -5,16 +5,16 @@ from rest_framework import serializers
import graphene
-from ..registry import get_global_registry
from ..converter import convert_choices_to_named_enum_with_descriptions
+from ..registry import get_global_registry
from .types import DictType
@singledispatch
def get_graphene_type_from_serializer_field(field):
raise ImproperlyConfigured(
- "Don't know how to convert the serializer field %s (%s) "
- "to Graphene type" % (field, field.__class__)
+ f"Don't know how to convert the serializer field {field} ({field.__class__}) "
+ "to Graphene type"
)
diff --git a/graphene_django/rest_framework/tests/test_field_converter.py b/graphene_django/rest_framework/tests/test_field_converter.py
index 8da8377..b0d7a6d 100644
--- a/graphene_django/rest_framework/tests/test_field_converter.py
+++ b/graphene_django/rest_framework/tests/test_field_converter.py
@@ -1,11 +1,11 @@
import copy
-import graphene
from django.db import models
-from graphene import InputObjectType
from pytest import raises
from rest_framework import serializers
+import graphene
+
from ..serializer_converter import convert_serializer_field
from ..types import DictType
diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py
index 5de8237..bfe53cc 100644
--- a/graphene_django/rest_framework/tests/test_mutation.py
+++ b/graphene_django/rest_framework/tests/test_mutation.py
@@ -7,7 +7,12 @@ from graphene import Field, ResolveInfo
from graphene.types.inputobjecttype import InputObjectType
from ...types import DjangoObjectType
-from ..models import MyFakeModel, MyFakeModelWithDate, MyFakeModelWithPassword
+from ..models import (
+ MyFakeModel,
+ MyFakeModelWithChoiceField,
+ MyFakeModelWithDate,
+ MyFakeModelWithPassword,
+)
from ..mutation import SerializerMutation
@@ -164,6 +169,21 @@ def test_read_only_fields():
), "'cool_name' is read_only field and shouldn't be on arguments"
+def test_hidden_fields():
+ class SerializerWithHiddenField(serializers.Serializer):
+ cool_name = serializers.CharField()
+ user = serializers.HiddenField(default=serializers.CurrentUserDefault())
+
+ class MyMutation(SerializerMutation):
+ class Meta:
+ serializer_class = SerializerWithHiddenField
+
+ assert "cool_name" in MyMutation.Input._meta.fields
+ assert (
+ "user" not in MyMutation.Input._meta.fields
+ ), "'user' is hidden field and shouldn't be on arguments"
+
+
def test_nested_model():
class MyFakeModelGrapheneType(DjangoObjectType):
class Meta:
@@ -230,7 +250,7 @@ def test_model_invalid_update_mutate_and_get_payload_success():
model_operations = ["update"]
with raises(Exception) as exc:
- result = InvalidModelMutation.mutate_and_get_payload(
+ InvalidModelMutation.mutate_and_get_payload(
None, mock_info(), **{"cool_name": "Narf"}
)
@@ -253,6 +273,39 @@ def test_perform_mutate_success():
assert result.days_since_last_edit == 4
+def test_perform_mutate_success_with_enum_choice_field():
+ class ListViewChoiceFieldSerializer(serializers.ModelSerializer):
+ choice_type = serializers.ChoiceField(
+ choices=[(x.name, x.value) for x in MyFakeModelWithChoiceField.ChoiceType],
+ required=False,
+ )
+
+ class Meta:
+ model = MyFakeModelWithChoiceField
+ fields = "__all__"
+
+ class SomeCreateSerializerMutation(SerializerMutation):
+ class Meta:
+ serializer_class = ListViewChoiceFieldSerializer
+
+ choice_type = {
+ "choice_type": SomeCreateSerializerMutation.Input.choice_type.type.get("ASDF")
+ }
+ name = MyFakeModelWithChoiceField.ChoiceType.ASDF.name
+ result = SomeCreateSerializerMutation.mutate_and_get_payload(
+ None, mock_info(), **choice_type
+ )
+ assert result.errors is None
+ assert result.choice_type == name
+ kwargs = SomeCreateSerializerMutation.get_serializer_kwargs(
+ None, mock_info(), **choice_type
+ )
+ assert kwargs["data"]["choice_type"] == name
+ assert 1 == MyFakeModelWithChoiceField.objects.count()
+ item = MyFakeModelWithChoiceField.objects.first()
+ assert item.choice_type == name
+
+
def test_mutate_and_get_payload_error():
class MyMutation(SerializerMutation):
class Meta:
diff --git a/graphene_django/settings.py b/graphene_django/settings.py
index 9c7dc38..d0ef16c 100644
--- a/graphene_django/settings.py
+++ b/graphene_django/settings.py
@@ -12,11 +12,10 @@ Graphene settings, checking for user settings first, then falling
back to the defaults.
"""
-from django.conf import settings
-from django.test.signals import setting_changed
-
import importlib # Available in Python 3.1+
+from django.conf import settings
+from django.test.signals import setting_changed
# Copied shamelessly from Django REST Framework
diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html
index ddff8fc..52421e8 100644
--- a/graphene_django/templates/graphene/graphiql.html
+++ b/graphene_django/templates/graphene/graphiql.html
@@ -21,6 +21,10 @@ add "&raw" to the end of the URL within a browser.
integrity="{{graphiql_css_sri}}"
rel="stylesheet"
crossorigin="anonymous" />
+
diff --git a/graphene_django/tests/issues/test_520.py b/graphene_django/tests/issues/test_520.py
index 4e55f96..700ae6f 100644
--- a/graphene_django/tests/issues/test_520.py
+++ b/graphene_django/tests/issues/test_520.py
@@ -1,21 +1,14 @@
# https://github.com/graphql-python/graphene-django/issues/520
-import datetime
from django import forms
+from rest_framework import serializers
import graphene
-from graphene import Field, ResolveInfo
-from graphene.types.inputobjecttype import InputObjectType
-from pytest import raises
-from pytest import mark
-from rest_framework import serializers
-
-from ...types import DjangoObjectType
+from ...forms.mutation import DjangoFormMutation
from ...rest_framework.models import MyFakeModel
from ...rest_framework.mutation import SerializerMutation
-from ...forms.mutation import DjangoFormMutation
class MyModelSerializer(serializers.ModelSerializer):
diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py
index 636f74c..4afbbbc 100644
--- a/graphene_django/tests/models.py
+++ b/graphene_django/tests/models.py
@@ -19,7 +19,11 @@ class Pet(models.Model):
class FilmDetails(models.Model):
location = models.CharField(max_length=30)
film = models.OneToOneField(
- "Film", on_delete=models.CASCADE, related_name="details"
+ "Film",
+ on_delete=models.CASCADE,
+ related_name="details",
+ null=True,
+ blank=True,
)
@@ -43,9 +47,10 @@ 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, blank=True)
+ a_choice = models.IntegerField(choices=CHOICES, null=True, blank=True)
objects = models.Manager()
doe_objects = DoeReporterManager()
+ fans = models.ManyToManyField(Person)
reporter_type = models.IntegerField(
"Reporter Type",
@@ -90,6 +95,16 @@ class CNNReporter(Reporter):
objects = CNNReporterManager()
+class APNewsReporter(Reporter):
+ """
+ This class only inherits from Reporter for testing multi table inheritence
+ similar to what you'd see in django-polymorphic
+ """
+
+ alias = models.CharField(max_length=30)
+ objects = models.Manager()
+
+
class Article(models.Model):
headline = models.CharField(max_length=100)
pub_date = models.DateField(auto_now_add=True)
diff --git a/graphene_django/tests/mutations.py b/graphene_django/tests/mutations.py
index 3aa8bfc..68247a2 100644
--- a/graphene_django/tests/mutations.py
+++ b/graphene_django/tests/mutations.py
@@ -1,5 +1,4 @@
from graphene import Field
-
from graphene_django.forms.mutation import DjangoFormMutation, DjangoModelFormMutation
from .forms import PetForm
diff --git a/graphene_django/tests/test_command.py b/graphene_django/tests/test_command.py
index a281abb..d209e03 100644
--- a/graphene_django/tests/test_command.py
+++ b/graphene_django/tests/test_command.py
@@ -1,8 +1,8 @@
+from io import StringIO
from textwrap import dedent
+from unittest.mock import mock_open, patch
from django.core import management
-from io import StringIO
-from unittest.mock import mock_open, patch
from graphene import ObjectType, Schema, String
@@ -46,7 +46,7 @@ def test_generate_graphql_file_on_call_graphql_schema():
open_mock.assert_called_once()
handle = open_mock()
- assert handle.write.called_once()
+ handle.write.assert_called_once()
schema_output = handle.write.call_args[0][0]
assert schema_output == dedent(
diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py
index 4996505..e8c0920 100644
--- a/graphene_django/tests/test_converter.py
+++ b/graphene_django/tests/test_converter.py
@@ -15,8 +15,6 @@ from graphene.types.scalars import BigInt
from ..compat import (
ArrayField,
HStoreField,
- JSONField,
- PGJSONField,
MissingType,
RangeField,
)
@@ -33,10 +31,10 @@ from .models import Article, Film, FilmDetails, Reporter
def assert_conversion(django_field, graphene_field, *args, **kwargs):
- _kwargs = kwargs.copy()
+ _kwargs = {**kwargs, "help_text": "Custom Help Text"}
if "null" not in kwargs:
_kwargs["null"] = True
- field = django_field(help_text="Custom Help Text", *args, **_kwargs)
+ field = django_field(*args, **_kwargs)
graphene_type = convert_django_field(field)
assert isinstance(graphene_type, graphene_field)
field = graphene_type.Field()
@@ -372,16 +370,6 @@ def test_should_postgres_hstore_convert_string():
assert_conversion(HStoreField, JSONString)
-@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)
-
-
@pytest.mark.skipif(RangeField is MissingType, reason="RangeField should exist")
def test_should_postgres_range_convert_list():
from django.contrib.postgres.fields import IntegerRangeField
diff --git a/graphene_django/tests/test_fields.py b/graphene_django/tests/test_fields.py
index 0ec6e82..eae5d5b 100644
--- a/graphene_django/tests/test_fields.py
+++ b/graphene_django/tests/test_fields.py
@@ -2,8 +2,8 @@ import datetime
import re
from django.db.models import Count, Prefetch
from asgiref.sync import sync_to_async, async_to_sync
-
import pytest
+from django.db.models import Count, Prefetch
from graphene import List, NonNull, ObjectType, Schema, String
@@ -24,7 +24,7 @@ class TestDjangoListField:
foo = String()
with pytest.raises(AssertionError):
- list_field = DjangoListField(TestType)
+ DjangoListField(TestType)
def test_only_import_paths(self):
list_field = DjangoListField("graphene_django.tests.schema.Human")
diff --git a/graphene_django/tests/test_forms.py b/graphene_django/tests/test_forms.py
index a42fcee..3957f01 100644
--- a/graphene_django/tests/test_forms.py
+++ b/graphene_django/tests/test_forms.py
@@ -3,7 +3,6 @@ from pytest import raises
from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField
-
# 'TXlUeXBlOmFiYw==' -> 'MyType', 'abc'
diff --git a/graphene_django/tests/test_get_queryset.py b/graphene_django/tests/test_get_queryset.py
index 7cbaa54..d5b1d93 100644
--- a/graphene_django/tests/test_get_queryset.py
+++ b/graphene_django/tests/test_get_queryset.py
@@ -1,14 +1,11 @@
import pytest
+from graphql_relay import to_global_id
import graphene
from graphene.relay import Node
-from graphql_relay import to_global_id
-
-from ..fields import DjangoConnectionField
from ..types import DjangoObjectType
-
-from .models import Article, Reporter
+from .models import Article, Film, FilmDetails, Reporter
class TestShouldCallGetQuerySetOnForeignKey:
@@ -127,6 +124,69 @@ class TestShouldCallGetQuerySetOnForeignKey:
assert not result.errors
assert result.data == {"reporter": {"firstName": "Jane"}}
+ def test_get_queryset_called_on_foreignkey(self):
+ # If a user tries to access a reporter through an article they should get our authorization error
+ query = """
+ query getArticle($id: ID!) {
+ article(id: $id) {
+ headline
+ reporter {
+ firstName
+ }
+ }
+ }
+ """
+
+ result = self.schema.execute(query, variables={"id": self.articles[0].id})
+ assert len(result.errors) == 1
+ assert result.errors[0].message == "Not authorized to access reporters."
+
+ # An admin user should be able to get reporters through an article
+ query = """
+ query getArticle($id: ID!) {
+ article(id: $id) {
+ headline
+ reporter {
+ firstName
+ }
+ }
+ }
+ """
+
+ result = self.schema.execute(
+ query,
+ variables={"id": self.articles[0].id},
+ context_value={"admin": True},
+ )
+ assert not result.errors
+ assert result.data["article"] == {
+ "headline": "A fantastic article",
+ "reporter": {"firstName": "Jane"},
+ }
+
+ # An admin user should not be able to access draft article through a reporter
+ query = """
+ query getReporter($id: ID!) {
+ reporter(id: $id) {
+ firstName
+ articles {
+ headline
+ }
+ }
+ }
+ """
+
+ result = self.schema.execute(
+ query,
+ variables={"id": self.reporter.id},
+ context_value={"admin": True},
+ )
+ assert not result.errors
+ assert result.data["reporter"] == {
+ "firstName": "Jane",
+ "articles": [{"headline": "A fantastic article"}],
+ }
+
class TestShouldCallGetQuerySetOnForeignKeyNode:
"""
@@ -233,3 +293,272 @@ class TestShouldCallGetQuerySetOnForeignKeyNode:
)
assert not result.errors
assert result.data == {"reporter": {"firstName": "Jane"}}
+
+ def test_get_queryset_called_on_foreignkey(self):
+ # If a user tries to access a reporter through an article they should get our authorization error
+ query = """
+ query getArticle($id: ID!) {
+ article(id: $id) {
+ headline
+ reporter {
+ firstName
+ }
+ }
+ }
+ """
+
+ result = self.schema.execute(
+ query, variables={"id": to_global_id("ArticleType", self.articles[0].id)}
+ )
+ assert len(result.errors) == 1
+ assert result.errors[0].message == "Not authorized to access reporters."
+
+ # An admin user should be able to get reporters through an article
+ query = """
+ query getArticle($id: ID!) {
+ article(id: $id) {
+ headline
+ reporter {
+ firstName
+ }
+ }
+ }
+ """
+
+ result = self.schema.execute(
+ query,
+ variables={"id": to_global_id("ArticleType", self.articles[0].id)},
+ context_value={"admin": True},
+ )
+ assert not result.errors
+ assert result.data["article"] == {
+ "headline": "A fantastic article",
+ "reporter": {"firstName": "Jane"},
+ }
+
+ # An admin user should not be able to access draft article through a reporter
+ query = """
+ query getReporter($id: ID!) {
+ reporter(id: $id) {
+ firstName
+ articles {
+ edges {
+ node {
+ headline
+ }
+ }
+ }
+ }
+ }
+ """
+
+ result = self.schema.execute(
+ query,
+ variables={"id": to_global_id("ReporterType", self.reporter.id)},
+ context_value={"admin": True},
+ )
+ assert not result.errors
+ assert result.data["reporter"] == {
+ "firstName": "Jane",
+ "articles": {"edges": [{"node": {"headline": "A fantastic article"}}]},
+ }
+
+
+class TestShouldCallGetQuerySetOnOneToOne:
+ @pytest.fixture(autouse=True)
+ def setup_schema(self):
+ class FilmDetailsType(DjangoObjectType):
+ class Meta:
+ model = FilmDetails
+
+ @classmethod
+ def get_queryset(cls, queryset, info):
+ if info.context and info.context.get("permission_get_film_details"):
+ return queryset
+ raise Exception("Not authorized to access film details.")
+
+ class FilmType(DjangoObjectType):
+ class Meta:
+ model = Film
+
+ @classmethod
+ def get_queryset(cls, queryset, info):
+ if info.context and info.context.get("permission_get_film"):
+ return queryset
+ raise Exception("Not authorized to access film.")
+
+ class Query(graphene.ObjectType):
+ film_details = graphene.Field(
+ FilmDetailsType, id=graphene.ID(required=True)
+ )
+ film = graphene.Field(FilmType, id=graphene.ID(required=True))
+
+ def resolve_film_details(self, info, id):
+ return (
+ FilmDetailsType.get_queryset(FilmDetails.objects, info)
+ .filter(id=id)
+ .last()
+ )
+
+ def resolve_film(self, info, id):
+ return FilmType.get_queryset(Film.objects, info).filter(id=id).last()
+
+ self.schema = graphene.Schema(query=Query)
+
+ self.films = [
+ Film.objects.create(
+ genre="do",
+ ),
+ Film.objects.create(
+ genre="ac",
+ ),
+ ]
+
+ self.film_details = [
+ FilmDetails.objects.create(
+ film=self.films[0],
+ ),
+ FilmDetails.objects.create(
+ film=self.films[1],
+ ),
+ ]
+
+ def test_get_queryset_called_on_field(self):
+ # A user tries to access a film
+ query = """
+ query getFilm($id: ID!) {
+ film(id: $id) {
+ genre
+ }
+ }
+ """
+
+ # With `permission_get_film`
+ result = self.schema.execute(
+ query,
+ variables={"id": self.films[0].id},
+ context_value={"permission_get_film": True},
+ )
+ assert not result.errors
+ assert result.data["film"] == {
+ "genre": "DO",
+ }
+
+ # Without `permission_get_film`
+ result = self.schema.execute(
+ query,
+ variables={"id": self.films[1].id},
+ context_value={"permission_get_film": False},
+ )
+ assert len(result.errors) == 1
+ assert result.errors[0].message == "Not authorized to access film."
+
+ # A user tries to access a film details
+ query = """
+ query getFilmDetails($id: ID!) {
+ filmDetails(id: $id) {
+ location
+ }
+ }
+ """
+
+ # With `permission_get_film`
+ result = self.schema.execute(
+ query,
+ variables={"id": self.film_details[0].id},
+ context_value={"permission_get_film_details": True},
+ )
+ assert not result.errors
+ assert result.data == {"filmDetails": {"location": ""}}
+
+ # Without `permission_get_film`
+ result = self.schema.execute(
+ query,
+ variables={"id": self.film_details[0].id},
+ context_value={"permission_get_film_details": False},
+ )
+ assert len(result.errors) == 1
+ assert result.errors[0].message == "Not authorized to access film details."
+
+ def test_get_queryset_called_on_foreignkey(self, django_assert_num_queries):
+ # A user tries to access a film details through a film
+ query = """
+ query getFilm($id: ID!) {
+ film(id: $id) {
+ genre
+ details {
+ location
+ }
+ }
+ }
+ """
+
+ # With `permission_get_film_details`
+ with django_assert_num_queries(2):
+ result = self.schema.execute(
+ query,
+ variables={"id": self.films[0].id},
+ context_value={
+ "permission_get_film": True,
+ "permission_get_film_details": True,
+ },
+ )
+ assert not result.errors
+ assert result.data["film"] == {
+ "genre": "DO",
+ "details": {"location": ""},
+ }
+
+ # Without `permission_get_film_details`
+ with django_assert_num_queries(1):
+ result = self.schema.execute(
+ query,
+ variables={"id": self.films[0].id},
+ context_value={
+ "permission_get_film": True,
+ "permission_get_film_details": False,
+ },
+ )
+ assert len(result.errors) == 1
+ assert result.errors[0].message == "Not authorized to access film details."
+
+ # A user tries to access a film through a film details
+ query = """
+ query getFilmDetails($id: ID!) {
+ filmDetails(id: $id) {
+ location
+ film {
+ genre
+ }
+ }
+ }
+ """
+
+ # With `permission_get_film`
+ with django_assert_num_queries(2):
+ result = self.schema.execute(
+ query,
+ variables={"id": self.film_details[0].id},
+ context_value={
+ "permission_get_film": True,
+ "permission_get_film_details": True,
+ },
+ )
+ assert not result.errors
+ assert result.data["filmDetails"] == {
+ "location": "",
+ "film": {"genre": "DO"},
+ }
+
+ # Without `permission_get_film`
+ with django_assert_num_queries(1):
+ result = self.schema.execute(
+ query,
+ variables={"id": self.film_details[1].id},
+ context_value={
+ "permission_get_film": False,
+ "permission_get_film_details": True,
+ },
+ )
+ assert len(result.errors) == 1
+ assert result.errors[0].message == "Not authorized to access film."
diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py
index 951ca79..4dd83e9 100644
--- a/graphene_django/tests/test_query.py
+++ b/graphene_django/tests/test_query.py
@@ -1,5 +1,5 @@
-import datetime
import base64
+import datetime
import pytest
from django.db import models
@@ -16,9 +16,17 @@ from ..compat import IntegerRangeField, MissingType
from ..fields import DjangoConnectionField
from ..types import DjangoObjectType
from ..utils import DJANGO_FILTER_INSTALLED
-from .models import Article, CNNReporter, Film, FilmDetails, Person, Pet, Reporter
from .async_test_helper import assert_async_result_equal
-
+from .models import (
+ APNewsReporter,
+ Article,
+ CNNReporter,
+ Film,
+ FilmDetails,
+ Person,
+ Pet,
+ Reporter,
+)
def test_should_query_only_fields():
with raises(Exception):
@@ -123,15 +131,14 @@ def test_should_query_well():
@pytest.mark.skipif(IntegerRangeField is MissingType, reason="RangeField should exist")
def test_should_query_postgres_fields():
from django.contrib.postgres.fields import (
- IntegerRangeField,
ArrayField,
- JSONField,
HStoreField,
+ IntegerRangeField,
)
class Event(models.Model):
ages = IntegerRangeField(help_text="The age ranges")
- data = JSONField(help_text="Data")
+ data = models.JSONField(help_text="Data")
store = HStoreField()
tags = ArrayField(models.CharField(max_length=50))
@@ -357,7 +364,7 @@ def test_should_query_connectionfields():
def test_should_keep_annotations():
- from django.db.models import Count, Avg
+ from django.db.models import Avg, Count
class ReporterType(DjangoObjectType):
class Meta:
@@ -521,7 +528,7 @@ def test_should_query_node_filtering_with_distinct_queryset():
).distinct()
f = Film.objects.create()
- fd = FilmDetails.objects.create(location="Berlin", film=f)
+ FilmDetails.objects.create(location="Berlin", film=f)
schema = graphene.Schema(query=Query)
query = """
@@ -646,7 +653,7 @@ def test_should_enforce_first_or_last(graphene_settings):
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
- r = Reporter.objects.create(
+ Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
@@ -689,7 +696,7 @@ def test_should_error_if_first_is_greater_than_max(graphene_settings):
assert Query.all_reporters.max_limit == 100
- r = Reporter.objects.create(
+ Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
@@ -732,7 +739,7 @@ def test_should_error_if_last_is_greater_than_max(graphene_settings):
assert Query.all_reporters.max_limit == 100
- r = Reporter.objects.create(
+ Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
@@ -798,7 +805,7 @@ def test_should_query_promise_connectionfields():
def test_should_query_connectionfields_with_last():
- r = Reporter.objects.create(
+ Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
@@ -836,11 +843,11 @@ def test_should_query_connectionfields_with_last():
def test_should_query_connectionfields_with_manager():
- r = Reporter.objects.create(
+ Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
- r = Reporter.objects.create(
+ Reporter.objects.create(
first_name="John", last_name="NotDoe", email="johndoe@example.com", a_choice=1
)
@@ -1186,11 +1193,306 @@ def test_proxy_model_support():
assert_async_result_equal(schema, query, result)
-def test_should_resolve_get_queryset_connectionfields():
- reporter_1 = Reporter.objects.create(
+def test_model_inheritance_support_reverse_relationships():
+ """
+ This test asserts that we can query reverse relationships for all Reporters and proxied Reporters and multi table Reporters.
+ """
+
+ class FilmType(DjangoObjectType):
+ class Meta:
+ model = Film
+ fields = "__all__"
+
+ class ReporterType(DjangoObjectType):
+ class Meta:
+ model = Reporter
+ interfaces = (Node,)
+ use_connection = True
+ fields = "__all__"
+
+ class CNNReporterType(DjangoObjectType):
+ class Meta:
+ model = CNNReporter
+ interfaces = (Node,)
+ use_connection = True
+ fields = "__all__"
+
+ class APNewsReporterType(DjangoObjectType):
+ class Meta:
+ model = APNewsReporter
+ interfaces = (Node,)
+ use_connection = True
+ fields = "__all__"
+
+ film = Film.objects.create(genre="do")
+
+ reporter = Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
- reporter_2 = CNNReporter.objects.create(
+
+ cnn_reporter = CNNReporter.objects.create(
+ first_name="Some",
+ last_name="Guy",
+ email="someguy@cnn.com",
+ a_choice=1,
+ reporter_type=2, # set this guy to be CNN
+ )
+
+ ap_news_reporter = APNewsReporter.objects.create(
+ first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
+ )
+
+ film.reporters.add(cnn_reporter, ap_news_reporter)
+ film.save()
+
+ class Query(graphene.ObjectType):
+ all_reporters = DjangoConnectionField(ReporterType)
+ cnn_reporters = DjangoConnectionField(CNNReporterType)
+ ap_news_reporters = DjangoConnectionField(APNewsReporterType)
+
+ schema = graphene.Schema(query=Query)
+ query = """
+ query ProxyModelQuery {
+ allReporters {
+ edges {
+ node {
+ id
+ films {
+ id
+ }
+ }
+ }
+ }
+ cnnReporters {
+ edges {
+ node {
+ id
+ films {
+ id
+ }
+ }
+ }
+ }
+ apNewsReporters {
+ edges {
+ node {
+ id
+ films {
+ id
+ }
+ }
+ }
+ }
+ }
+ """
+
+ expected = {
+ "allReporters": {
+ "edges": [
+ {
+ "node": {
+ "id": to_global_id("ReporterType", reporter.id),
+ "films": [],
+ },
+ },
+ {
+ "node": {
+ "id": to_global_id("ReporterType", cnn_reporter.id),
+ "films": [{"id": f"{film.id}"}],
+ },
+ },
+ {
+ "node": {
+ "id": to_global_id("ReporterType", ap_news_reporter.id),
+ "films": [{"id": f"{film.id}"}],
+ },
+ },
+ ]
+ },
+ "cnnReporters": {
+ "edges": [
+ {
+ "node": {
+ "id": to_global_id("CNNReporterType", cnn_reporter.id),
+ "films": [{"id": f"{film.id}"}],
+ }
+ }
+ ]
+ },
+ "apNewsReporters": {
+ "edges": [
+ {
+ "node": {
+ "id": to_global_id("APNewsReporterType", ap_news_reporter.id),
+ "films": [{"id": f"{film.id}"}],
+ }
+ }
+ ]
+ },
+ }
+
+ result = schema.execute(query)
+ assert result.data == expected
+
+
+def test_model_inheritance_support_local_relationships():
+ """
+ This test asserts that we can query local relationships for all Reporters and proxied Reporters and multi table Reporters.
+ """
+
+ class PersonType(DjangoObjectType):
+ class Meta:
+ model = Person
+ fields = "__all__"
+
+ class ReporterType(DjangoObjectType):
+ class Meta:
+ model = Reporter
+ interfaces = (Node,)
+ use_connection = True
+ fields = "__all__"
+
+ class CNNReporterType(DjangoObjectType):
+ class Meta:
+ model = CNNReporter
+ interfaces = (Node,)
+ use_connection = True
+ fields = "__all__"
+
+ class APNewsReporterType(DjangoObjectType):
+ class Meta:
+ model = APNewsReporter
+ interfaces = (Node,)
+ use_connection = True
+ fields = "__all__"
+
+ film = Film.objects.create(genre="do")
+
+ reporter = Reporter.objects.create(
+ first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
+ )
+
+ reporter_fan = Person.objects.create(name="Reporter Fan")
+
+ reporter.fans.add(reporter_fan)
+ reporter.save()
+
+ cnn_reporter = CNNReporter.objects.create(
+ first_name="Some",
+ last_name="Guy",
+ email="someguy@cnn.com",
+ a_choice=1,
+ reporter_type=2, # set this guy to be CNN
+ )
+ cnn_fan = Person.objects.create(name="CNN Fan")
+ cnn_reporter.fans.add(cnn_fan)
+ cnn_reporter.save()
+
+ ap_news_reporter = APNewsReporter.objects.create(
+ first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
+ )
+ ap_news_fan = Person.objects.create(name="AP News Fan")
+ ap_news_reporter.fans.add(ap_news_fan)
+ ap_news_reporter.save()
+
+ film.reporters.add(cnn_reporter, ap_news_reporter)
+ film.save()
+
+ class Query(graphene.ObjectType):
+ all_reporters = DjangoConnectionField(ReporterType)
+ cnn_reporters = DjangoConnectionField(CNNReporterType)
+ ap_news_reporters = DjangoConnectionField(APNewsReporterType)
+
+ schema = graphene.Schema(query=Query)
+ query = """
+ query ProxyModelQuery {
+ allReporters {
+ edges {
+ node {
+ id
+ fans {
+ name
+ }
+ }
+ }
+ }
+ cnnReporters {
+ edges {
+ node {
+ id
+ fans {
+ name
+ }
+ }
+ }
+ }
+ apNewsReporters {
+ edges {
+ node {
+ id
+ fans {
+ name
+ }
+ }
+ }
+ }
+ }
+ """
+
+ expected = {
+ "allReporters": {
+ "edges": [
+ {
+ "node": {
+ "id": to_global_id("ReporterType", reporter.id),
+ "fans": [{"name": f"{reporter_fan.name}"}],
+ },
+ },
+ {
+ "node": {
+ "id": to_global_id("ReporterType", cnn_reporter.id),
+ "fans": [{"name": f"{cnn_fan.name}"}],
+ },
+ },
+ {
+ "node": {
+ "id": to_global_id("ReporterType", ap_news_reporter.id),
+ "fans": [{"name": f"{ap_news_fan.name}"}],
+ },
+ },
+ ]
+ },
+ "cnnReporters": {
+ "edges": [
+ {
+ "node": {
+ "id": to_global_id("CNNReporterType", cnn_reporter.id),
+ "fans": [{"name": f"{cnn_fan.name}"}],
+ }
+ }
+ ]
+ },
+ "apNewsReporters": {
+ "edges": [
+ {
+ "node": {
+ "id": to_global_id("APNewsReporterType", ap_news_reporter.id),
+ "fans": [{"name": f"{ap_news_fan.name}"}],
+ }
+ }
+ ]
+ },
+ }
+
+ result = schema.execute(query)
+ assert result.data == expected
+
+
+def test_should_resolve_get_queryset_connectionfields():
+ Reporter.objects.create(
+ first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
+ )
+ CNNReporter.objects.create(
first_name="Some",
last_name="Guy",
email="someguy@cnn.com",
@@ -1233,10 +1535,10 @@ def test_should_resolve_get_queryset_connectionfields():
def test_connection_should_limit_after_to_list_length():
- reporter_1 = Reporter.objects.create(
+ Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
- reporter_2 = Reporter.objects.create(
+ Reporter.objects.create(
first_name="Some", last_name="Guy", email="someguy@cnn.com", a_choice=1
)
@@ -1263,7 +1565,7 @@ def test_connection_should_limit_after_to_list_length():
"""
after = base64.b64encode(b"arrayconnection:10").decode()
- result = schema.execute(query, variable_values=dict(after=after))
+ result = schema.execute(query, variable_values={"after": after})
expected = {"allReporters": {"edges": []}}
assert not result.errors
assert result.data == expected
@@ -1271,12 +1573,12 @@ def test_connection_should_limit_after_to_list_length():
REPORTERS = [
- dict(
- first_name=f"First {i}",
- last_name=f"Last {i}",
- email=f"johndoe+{i}@example.com",
- a_choice=1,
- )
+ {
+ "first_name": f"First {i}",
+ "last_name": f"Last {i}",
+ "email": f"johndoe+{i}@example.com",
+ "a_choice": 1,
+ }
for i in range(6)
]
@@ -1353,7 +1655,7 @@ def test_should_have_next_page(graphene_settings):
assert_async_result_equal(schema, query, result, variable_values={})
last_result = result.data["allReporters"]["pageInfo"]["endCursor"]
- result2 = schema.execute(query, variable_values=dict(first=4, after=last_result))
+ result2 = schema.execute(query, variable_values={"first": 4, "after": last_result})
assert not result2.errors
assert len(result2.data["allReporters"]["edges"]) == 2
assert not result2.data["allReporters"]["pageInfo"]["hasNextPage"]
@@ -1448,8 +1750,8 @@ class TestBackwardPagination:
after = base64.b64encode(b"arrayconnection:0").decode()
result = schema.execute(
- query,
- variable_values=dict(after=after),
+ query_first_last_and_after,
+ variable_values={"after": after},
)
assert not result.errors
assert len(result.data["allReporters"]["edges"]) == 3
@@ -1484,8 +1786,8 @@ class TestBackwardPagination:
before = base64.b64encode(b"arrayconnection:5").decode()
result = schema.execute(
- query,
- variable_values=dict(before=before),
+ query_first_last_and_after,
+ variable_values={"before": before},
)
assert not result.errors
assert len(result.data["allReporters"]["edges"]) == 1
@@ -1544,7 +1846,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(3):
result = schema.execute(query)
assert not result.errors
assert_async_result_equal(schema, query, result)
@@ -1715,7 +2017,7 @@ def test_connection_should_forbid_offset_filtering_with_before():
}
"""
before = base64.b64encode(b"arrayconnection:2").decode()
- result = schema.execute(query, variable_values=dict(before=before))
+ result = schema.execute(query, variable_values={"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
@@ -1754,7 +2056,7 @@ def test_connection_should_allow_offset_filtering_with_after():
"""
after = base64.b64encode(b"arrayconnection:0").decode()
- result = schema.execute(query, variable_values=dict(after=after))
+ result = schema.execute(query, variable_values={"after": after})
assert not result.errors
expected = {
"allReporters": {
@@ -1791,7 +2093,7 @@ def test_connection_should_succeed_if_last_higher_than_number_of_objects():
}
"""
- result = schema.execute(query, variable_values=dict(last=2))
+ result = schema.execute(query, variable_values={"last": 2})
assert not result.errors
expected = {"allReporters": {"edges": []}}
assert result.data == expected
@@ -1802,7 +2104,7 @@ def test_connection_should_succeed_if_last_higher_than_number_of_objects():
Reporter.objects.create(first_name="Jane", last_name="Roe")
Reporter.objects.create(first_name="Some", last_name="Lady")
- result = schema.execute(query, variable_values=dict(last=2))
+ result = schema.execute(query, variable_values={"last": 2})
assert not result.errors
expected = {
"allReporters": {
@@ -1815,7 +2117,7 @@ def test_connection_should_succeed_if_last_higher_than_number_of_objects():
assert result.data == expected
assert_async_result_equal(schema, query, result, variable_values=dict(last=2))
- result = schema.execute(query, variable_values=dict(last=4))
+ result = schema.execute(query, variable_values={"last": 4})
assert not result.errors
expected = {
"allReporters": {
@@ -1830,7 +2132,7 @@ def test_connection_should_succeed_if_last_higher_than_number_of_objects():
assert result.data == expected
assert_async_result_equal(schema, query, result, variable_values=dict(last=4))
- result = schema.execute(query, variable_values=dict(last=20))
+ result = schema.execute(query, variable_values={"last": 20})
assert not result.errors
expected = {
"allReporters": {
@@ -1868,7 +2170,7 @@ def test_should_query_nullable_foreign_key():
schema = graphene.Schema(query=Query)
person = Person.objects.create(name="Jane")
- pets = [
+ [
Pet.objects.create(name="Stray dog", age=1),
Pet.objects.create(name="Jane's dog", owner=person, age=1),
]
@@ -1908,3 +2210,74 @@ def test_should_query_nullable_foreign_key():
assert result.data["person"] == {
"pets": [{"name": "Jane's dog"}],
}
+
+
+def test_should_query_nullable_one_to_one_relation_with_custom_resolver():
+ class FilmType(DjangoObjectType):
+ class Meta:
+ model = Film
+
+ @classmethod
+ def get_queryset(cls, queryset, info):
+ return queryset
+
+ class FilmDetailsType(DjangoObjectType):
+ class Meta:
+ model = FilmDetails
+
+ @classmethod
+ def get_queryset(cls, queryset, info):
+ return queryset
+
+ class Query(graphene.ObjectType):
+ film = graphene.Field(FilmType, genre=graphene.String(required=True))
+ film_details = graphene.Field(
+ FilmDetailsType, location=graphene.String(required=True)
+ )
+
+ def resolve_film(self, info, genre):
+ return Film.objects.filter(genre=genre).first()
+
+ def resolve_film_details(self, info, location):
+ return FilmDetails.objects.filter(location=location).first()
+
+ schema = graphene.Schema(query=Query)
+
+ Film.objects.create(genre="do")
+ FilmDetails.objects.create(location="London")
+
+ query_film = """
+ query getFilm($genre: String!) {
+ film(genre: $genre) {
+ genre
+ details {
+ location
+ }
+ }
+ }
+ """
+
+ query_film_details = """
+ query getFilmDetails($location: String!) {
+ filmDetails(location: $location) {
+ location
+ film {
+ genre
+ }
+ }
+ }
+ """
+
+ result = schema.execute(query_film, variables={"genre": "do"})
+ assert not result.errors
+ assert result.data["film"] == {
+ "genre": "DO",
+ "details": None,
+ }
+
+ result = schema.execute(query_film_details, variables={"location": "London"})
+ assert not result.errors
+ assert result.data["filmDetails"] == {
+ "location": "London",
+ "film": None,
+ }
diff --git a/graphene_django/tests/test_schema.py b/graphene_django/tests/test_schema.py
index ff2d8a6..93cbd9f 100644
--- a/graphene_django/tests/test_schema.py
+++ b/graphene_django/tests/test_schema.py
@@ -33,17 +33,18 @@ def test_should_map_fields_correctly():
fields = "__all__"
fields = list(ReporterType2._meta.fields.keys())
- assert fields[:-2] == [
+ assert fields[:-3] == [
"id",
"first_name",
"last_name",
"email",
"pets",
"a_choice",
+ "fans",
"reporter_type",
]
- assert sorted(fields[-2:]) == ["articles", "films"]
+ assert sorted(fields[-3:]) == ["apnewsreporter", "articles", "films"]
def test_should_map_only_few_fields():
diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py
index fad26e2..34828db 100644
--- a/graphene_django/tests/test_types.py
+++ b/graphene_django/tests/test_types.py
@@ -1,9 +1,9 @@
from collections import OrderedDict, defaultdict
from textwrap import dedent
+from unittest.mock import patch
import pytest
from django.db import models
-from unittest.mock import patch
from graphene import Connection, Field, Interface, ObjectType, Schema, String
from graphene.relay import Node
@@ -11,8 +11,10 @@ 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
+from .models import (
+ Article as ArticleModel,
+ Reporter as ReporterModel,
+)
class Reporter(DjangoObjectType):
@@ -67,16 +69,17 @@ def test_django_get_node(get):
def test_django_objecttype_map_correct_fields():
fields = Reporter._meta.fields
fields = list(fields.keys())
- assert fields[:-2] == [
+ assert fields[:-3] == [
"id",
"first_name",
"last_name",
"email",
"pets",
"a_choice",
+ "fans",
"reporter_type",
]
- assert sorted(fields[-2:]) == ["articles", "films"]
+ assert sorted(fields[-3:]) == ["apnewsreporter", "articles", "films"]
def test_django_objecttype_with_node_have_correct_fields():
diff --git a/graphene_django/tests/test_utils.py b/graphene_django/tests/test_utils.py
index fa269b4..3fa8ba4 100644
--- a/graphene_django/tests/test_utils.py
+++ b/graphene_django/tests/test_utils.py
@@ -1,12 +1,12 @@
import json
+from unittest.mock import patch
import pytest
from django.utils.translation import gettext_lazy
-from unittest.mock import patch
-from ..utils import camelize, get_model_fields, GraphQLTestCase
-from .models import Film, Reporter
+from ..utils import GraphQLTestCase, camelize, get_model_fields, get_reverse_fields
from ..utils.testing import graphql_query
+from .models import APNewsReporter, CNNReporter, Film, Reporter
def test_get_model_fields_no_duplication():
@@ -19,6 +19,18 @@ def test_get_model_fields_no_duplication():
assert len(film_fields) == len(film_name_set)
+def test_get_reverse_fields_includes_proxied_models():
+ reporter_fields = get_reverse_fields(Reporter, [])
+ cnn_reporter_fields = get_reverse_fields(CNNReporter, [])
+ ap_news_reporter_fields = get_reverse_fields(APNewsReporter, [])
+
+ assert (
+ len(list(reporter_fields))
+ == len(list(cnn_reporter_fields))
+ == len(list(ap_news_reporter_fields))
+ )
+
+
def test_camelize():
assert camelize({}) == {}
assert camelize("value_a") == "value_a"
diff --git a/graphene_django/tests/test_views.py b/graphene_django/tests/test_views.py
index 5cadefe..d64a4f0 100644
--- a/graphene_django/tests/test_views.py
+++ b/graphene_django/tests/test_views.py
@@ -1,13 +1,9 @@
import json
-
-import pytest
-
from unittest.mock import patch
+import pytest
from django.db import connection
-from graphene_django.settings import graphene_settings
-
from .models import Pet
try:
@@ -31,8 +27,12 @@ def response_json(response):
return json.loads(response.content.decode())
-j = lambda **kwargs: json.dumps(kwargs)
-jl = lambda **kwargs: json.dumps([kwargs])
+def j(**kwargs):
+ return json.dumps(kwargs)
+
+
+def jl(**kwargs):
+ return json.dumps([kwargs])
def test_graphiql_is_enabled(client):
@@ -229,7 +229,7 @@ def test_allows_sending_a_mutation_via_post(client):
def test_allows_post_with_url_encoding(client):
response = client.post(
url_string(),
- urlencode(dict(query="{test}")),
+ urlencode({"query": "{test}"}),
"application/x-www-form-urlencoded",
)
@@ -303,10 +303,10 @@ def test_supports_post_url_encoded_query_with_string_variables(client):
response = client.post(
url_string(),
urlencode(
- dict(
- query="query helloWho($who: String){ test(who: $who) }",
- variables=json.dumps({"who": "Dolly"}),
- )
+ {
+ "query": "query helloWho($who: String){ test(who: $who) }",
+ "variables": json.dumps({"who": "Dolly"}),
+ }
),
"application/x-www-form-urlencoded",
)
@@ -329,7 +329,7 @@ def test_supports_post_json_quey_with_get_variable_values(client):
def test_post_url_encoded_query_with_get_variable_values(client):
response = client.post(
url_string(variables=json.dumps({"who": "Dolly"})),
- urlencode(dict(query="query helloWho($who: String){ test(who: $who) }")),
+ urlencode({"query": "query helloWho($who: String){ test(who: $who) }"}),
"application/x-www-form-urlencoded",
)
@@ -511,7 +511,7 @@ def test_handles_django_request_error(client, monkeypatch):
monkeypatch.setattr("django.http.request.HttpRequest.read", mocked_read)
- valid_json = json.dumps(dict(foo="bar"))
+ valid_json = json.dumps({"foo": "bar"})
response = client.post(url_string(), valid_json, "application/json")
assert response.status_code == 400
diff --git a/graphene_django/types.py b/graphene_django/types.py
index 2ce9db3..5dfbe55 100644
--- a/graphene_django/types.py
+++ b/graphene_django/types.py
@@ -1,9 +1,10 @@
import warnings
from collections import OrderedDict
-from typing import Type
+from typing import Type # noqa: F401
+
+from django.db.models import Model # noqa: F401
import graphene
-from django.db.models import Model
from graphene.relay import Connection, Node
from graphene.types.objecttype import ObjectType, ObjectTypeOptions
from graphene.types.utils import yank_fields_from_attrs
@@ -150,7 +151,7 @@ class DjangoObjectType(ObjectType):
interfaces=(),
convert_choices_to_enum=True,
_meta=None,
- **options
+ **options,
):
assert is_valid_django_model(model), (
'You need to pass a valid Django Model in {}.Meta, received "{}".'
@@ -160,9 +161,9 @@ class DjangoObjectType(ObjectType):
registry = get_global_registry()
assert isinstance(registry, Registry), (
- "The attribute registry in {} needs to be an instance of "
- 'Registry, received "{}".'
- ).format(cls.__name__, registry)
+ f"The attribute registry in {cls.__name__} needs to be an instance of "
+ f'Registry, received "{registry}".'
+ )
if filter_fields and filterset_class:
raise Exception("Can't set both filter_fields and filterset_class")
@@ -175,7 +176,7 @@ class DjangoObjectType(ObjectType):
assert not (fields and exclude), (
"Cannot set both 'fields' and 'exclude' options on "
- "DjangoObjectType {class_name}.".format(class_name=cls.__name__)
+ f"DjangoObjectType {cls.__name__}."
)
# Alias only_fields -> fields
@@ -214,8 +215,8 @@ class DjangoObjectType(ObjectType):
warnings.warn(
"Creating a DjangoObjectType without either the `fields` "
"or the `exclude` option is deprecated. Add an explicit `fields "
- "= '__all__'` option on DjangoObjectType {class_name} to use all "
- "fields".format(class_name=cls.__name__),
+ f"= '__all__'` option on DjangoObjectType {cls.__name__} to use all "
+ "fields",
DeprecationWarning,
stacklevel=2,
)
@@ -240,9 +241,9 @@ class DjangoObjectType(ObjectType):
)
if connection is not None:
- assert issubclass(connection, Connection), (
- "The connection must be a Connection. Received {}"
- ).format(connection.__name__)
+ assert issubclass(
+ connection, Connection
+ ), f"The connection must be a Connection. Received {connection.__name__}"
if not _meta:
_meta = DjangoObjectTypeOptions(cls)
@@ -253,6 +254,7 @@ class DjangoObjectType(ObjectType):
_meta.filterset_class = filterset_class
_meta.fields = django_fields
_meta.connection = connection
+ _meta.convert_choices_to_enum = convert_choices_to_enum
super().__init_subclass_with_meta__(
_meta=_meta, interfaces=interfaces, **options
@@ -272,7 +274,7 @@ class DjangoObjectType(ObjectType):
if isinstance(root, cls):
return True
if not is_valid_django_model(root.__class__):
- raise Exception(('Received incompatible instance "{}".').format(root))
+ raise Exception(f'Received incompatible instance "{root}".')
if cls._meta.model._meta.proxy:
model = root._meta.model
diff --git a/graphene_django/utils/__init__.py b/graphene_django/utils/__init__.py
index 7344ce5..b1a11cf 100644
--- a/graphene_django/utils/__init__.py
+++ b/graphene_django/utils/__init__.py
@@ -1,6 +1,7 @@
from .testing import GraphQLTestCase
from .utils import (
DJANGO_FILTER_INSTALLED,
+ bypass_get_queryset,
camelize,
get_model_fields,
get_reverse_fields,
@@ -20,4 +21,5 @@ __all__ = [
"GraphQLTestCase",
"is_sync_function",
"is_running_async",
+ "bypass_get_queryset",
]
diff --git a/graphene_django/utils/str_converters.py b/graphene_django/utils/str_converters.py
index 77a0f37..03ad64d 100644
--- a/graphene_django/utils/str_converters.py
+++ b/graphene_django/utils/str_converters.py
@@ -1,4 +1,5 @@
import re
+
from text_unidecode import unidecode
diff --git a/graphene_django/utils/tests/test_testing.py b/graphene_django/utils/tests/test_testing.py
index de56158..801708e 100644
--- a/graphene_django/utils/tests/test_testing.py
+++ b/graphene_django/utils/tests/test_testing.py
@@ -1,10 +1,10 @@
import pytest
-
-from .. import GraphQLTestCase
-from ...tests.test_types import with_local_registry
-from ...settings import graphene_settings
from django.test import Client
+from ...settings import graphene_settings
+from ...tests.test_types import with_local_registry
+from .. import GraphQLTestCase
+
@with_local_registry
def test_graphql_test_case_deprecated_client_getter():
@@ -23,7 +23,7 @@ def test_graphql_test_case_deprecated_client_getter():
tc.setUpClass()
with pytest.warns(PendingDeprecationWarning):
- tc._client
+ tc._client # noqa: B018
@with_local_registry
diff --git a/graphene_django/utils/utils.py b/graphene_django/utils/utils.py
index beac3e6..1b9760a 100644
--- a/graphene_django/utils/utils.py
+++ b/graphene_django/utils/utils.py
@@ -38,18 +38,52 @@ def camelize(data):
return data
-def get_reverse_fields(model, local_field_names):
- for name, attr in model.__dict__.items():
- # Don't duplicate any local fields
- if name in local_field_names:
- continue
+def _get_model_ancestry(model):
+ model_ancestry = [model]
- # "rel" for FK and M2M relations and "related" for O2O Relations
- related = getattr(attr, "rel", None) or getattr(attr, "related", None)
- if isinstance(related, models.ManyToOneRel):
- yield (name, related)
- elif isinstance(related, models.ManyToManyRel) and not related.symmetrical:
- yield (name, related)
+ for base in model.__bases__:
+ if is_valid_django_model(base) and getattr(base, "_meta", False):
+ model_ancestry.append(base)
+ return model_ancestry
+
+
+def get_reverse_fields(model, local_field_names):
+ """
+ Searches through the model's ancestry and gets reverse relationships the models
+ Yields a tuple of (field.name, field)
+ """
+ model_ancestry = _get_model_ancestry(model)
+
+ for _model in model_ancestry:
+ for name, attr in _model.__dict__.items():
+ # Don't duplicate any local fields
+ if name in local_field_names:
+ continue
+
+ # "rel" for FK and M2M relations and "related" for O2O Relations
+ related = getattr(attr, "rel", None) or getattr(attr, "related", None)
+ if isinstance(related, models.ManyToOneRel):
+ yield (name, related)
+ elif isinstance(related, models.ManyToManyRel) and not related.symmetrical:
+ yield (name, related)
+
+
+def get_local_fields(model):
+ """
+ Searches through the model's ancestry and gets the fields on the models
+ Returns a dict of {field.name: field}
+ """
+ model_ancestry = _get_model_ancestry(model)
+
+ local_fields_dict = {}
+ for _model in model_ancestry:
+ for field in sorted(
+ list(_model._meta.fields) + list(_model._meta.local_many_to_many)
+ ):
+ if field.name not in local_fields_dict:
+ local_fields_dict[field.name] = field
+
+ return list(local_fields_dict.items())
def maybe_queryset(value):
@@ -59,17 +93,14 @@ def maybe_queryset(value):
def get_model_fields(model):
- local_fields = [
- (field.name, field)
- for field in sorted(
- list(model._meta.fields) + list(model._meta.local_many_to_many)
- )
- ]
-
- # Make sure we don't duplicate local fields with "reverse" version
- local_field_names = [field[0] for field in local_fields]
+ """
+ Gets all the fields and relationships on the Django model and its ancestry.
+ Prioritizes local fields and relationships over the reverse relationships of the same name
+ Returns a tuple of (field.name, field)
+ """
+ local_fields = get_local_fields(model)
+ local_field_names = {field[0] for field in local_fields}
reverse_fields = get_reverse_fields(model, local_field_names)
-
all_fields = local_fields + list(reverse_fields)
return all_fields
@@ -121,3 +152,11 @@ def is_sync_function(func):
return not inspect.iscoroutinefunction(func) and not inspect.isasyncgenfunction(
func
)
+
+def bypass_get_queryset(resolver):
+ """
+ Adds a bypass_get_queryset attribute to the resolver, which is used to
+ bypass any custom get_queryset method of the DjangoObjectType.
+ """
+ resolver._bypass_get_queryset = True
+ return resolver
diff --git a/graphene_django/views.py b/graphene_django/views.py
index 8cdc1d1..1f3a90b 100644
--- a/graphene_django/views.py
+++ b/graphene_django/views.py
@@ -16,10 +16,9 @@ from django.views.generic import View
from graphql import OperationType, get_operation_ast, parse
from graphql.error import GraphQLError
from graphql.execution import ExecutionResult
-
-from graphene import Schema
from graphql.execution.middleware import MiddlewareManager
+from graphene import Schema
from graphene_django.constants import MUTATION_ERRORS_FLAG
from graphene_django.utils.utils import set_rollback
@@ -44,9 +43,9 @@ def get_accepted_content_types(request):
raw_content_types = request.META.get("HTTP_ACCEPT", "*/*").split(",")
qualified_content_types = map(qualify, raw_content_types)
- return list(
+ return [
x[0] for x in sorted(qualified_content_types, key=lambda x: x[1], reverse=True)
- )
+ ]
def instantiate_middleware(middlewares):
@@ -70,18 +69,21 @@ class GraphQLView(View):
react_dom_sri = "sha256-nbMykgB6tsOFJ7OdVmPpdqMFVk4ZsqWocT6issAPUF0="
# The GraphiQL React app.
- graphiql_version = "2.4.1" # "1.0.3"
- graphiql_sri = "sha256-s+f7CFAPSUIygFnRC2nfoiEKd3liCUy+snSdYFAoLUc=" # "sha256-VR4buIDY9ZXSyCNFHFNik6uSe0MhigCzgN4u7moCOTk="
- graphiql_css_sri = "sha256-88yn8FJMyGboGs4Bj+Pbb3kWOWXo7jmb+XCRHE+282k=" # "sha256-LwqxjyZgqXDYbpxQJ5zLQeNcf7WVNSJ+r8yp2rnWE/E="
+ graphiql_version = "2.4.7"
+ graphiql_sri = "sha256-n/LKaELupC1H/PU6joz+ybeRJHT2xCdekEt6OYMOOZU="
+ graphiql_css_sri = "sha256-OsbM+LQHcnFHi0iH7AUKueZvDcEBoy/z4hJ7jx1cpsM="
# The websocket transport library for subscriptions.
- subscriptions_transport_ws_version = "5.12.1"
+ subscriptions_transport_ws_version = "5.13.1"
subscriptions_transport_ws_sri = (
"sha256-EZhvg6ANJrBsgLvLAa0uuHNLepLJVCFYS+xlb5U/bqw="
)
graphiql_plugin_explorer_version = "0.1.15"
graphiql_plugin_explorer_sri = "sha256-3hUuhBXdXlfCj6RTeEkJFtEh/kUG+TCDASFpFPLrzvE="
+ graphiql_plugin_explorer_css_sri = (
+ "sha256-fA0LPUlukMNR6L4SPSeFqDTYav8QdWjQ2nr559Zln1U="
+ )
schema = None
graphiql = False
@@ -109,17 +111,19 @@ class GraphQLView(View):
if middleware is None:
middleware = graphene_settings.MIDDLEWARE
- self.schema = self.schema or schema
+ self.schema = schema or self.schema
if middleware is not None:
if isinstance(middleware, MiddlewareManager):
self.middleware = middleware
else:
self.middleware = list(instantiate_middleware(middleware))
self.root_value = root_value
- self.pretty = self.pretty or pretty
- self.graphiql = self.graphiql or graphiql
- self.batch = self.batch or batch
- self.execution_context_class = execution_context_class
+ self.pretty = pretty or self.pretty
+ self.graphiql = graphiql or self.graphiql
+ self.batch = batch or self.batch
+ self.execution_context_class = (
+ execution_context_class or self.execution_context_class
+ )
if subscription_path is None:
self.subscription_path = graphene_settings.SUBSCRIPTION_PATH
diff --git a/setup.cfg b/setup.cfg
index c725df1..bd6d271 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -4,46 +4,9 @@ test=pytest
[bdist_wheel]
universal=1
-[flake8]
-exclude = docs,graphene_django/debug/sql/*
-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/*
-[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
-
[tool:pytest]
DJANGO_SETTINGS_MODULE = examples.django_test_settings
addopts = --random-order
diff --git a/setup.py b/setup.py
index 9ff5d3f..6849bc4 100644
--- a/setup.py
+++ b/setup.py
@@ -27,10 +27,8 @@ tests_require = [
dev_requires = [
- "black==23.3.0",
- "flake8==6.0.0",
- "flake8-black==0.3.6",
- "flake8-bugbear==23.3.23",
+ "black==23.7.0",
+ "ruff==0.0.283",
"pre-commit",
] + tests_require
@@ -39,6 +37,7 @@ setup(
version=version,
description="Graphene Django integration",
long_description=open("README.md").read(),
+ long_description_content_type="text/markdown",
url="https://github.com/graphql-python/graphene-django",
author="Syrus Akbary",
author_email="me@syrusakbary.com",
@@ -48,7 +47,6 @@ setup(
"Intended Audience :: Developers",
"Topic :: Software Development :: Libraries",
"Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
@@ -56,8 +54,8 @@ setup(
"Programming Language :: Python :: Implementation :: PyPy",
"Framework :: Django",
"Framework :: Django :: 3.2",
- "Framework :: Django :: 4.0",
"Framework :: Django :: 4.1",
+ "Framework :: Django :: 4.2",
],
keywords="api graphql protocol rest relay graphene",
packages=find_packages(exclude=["tests", "examples", "examples.*"]),
diff --git a/tox.ini b/tox.ini
index e186f30..41586ba 100644
--- a/tox.ini
+++ b/tox.ini
@@ -1,13 +1,12 @@
[tox]
envlist =
- py{37,38,39,310}-django32,
- py{38,39,310}-django{40,41,main},
- py311-django{41,main}
+ py{38,39,310}-django32
+ py{38,39}-django{41,42}
+ py{310,311}-django{41,42,main}
pre-commit
[gh-actions]
python =
- 3.7: py37
3.8: py38
3.9: py39
3.10: py310
@@ -16,8 +15,8 @@ python =
[gh-actions:env]
DJANGO =
3.2: django32
- 4.0: django40
4.1: django41
+ 4.2: django42
main: djangomain
[testenv]
@@ -30,13 +29,13 @@ deps =
-e.[test]
psycopg2-binary
django32: Django>=3.2,<4.0
- django40: Django>=4.0,<4.1
django41: Django>=4.1,<4.2
+ django42: Django>=4.2,<4.3
djangomain: https://github.com/django/django/archive/main.zip
-commands = {posargs:py.test --cov=graphene_django graphene_django examples}
+commands = {posargs:pytest --cov=graphene_django graphene_django examples}
[testenv:pre-commit]
skip_install = true
deps = pre-commit
commands =
- pre-commit run --all-files --show-diff-on-failure
+ pre-commit run {posargs:--all-files --show-diff-on-failure}