Merge branch 'v3' into chore/default-camelcase-errors

This commit is contained in:
Jonathan Kim 2020-05-21 16:27:53 +01:00 committed by GitHub
commit 18dac205ea
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
67 changed files with 2075 additions and 918 deletions

8
.github/stale.yml vendored
View File

@ -1,11 +1,15 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 60
daysUntilStale: 120
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
daysUntilClose: 30
# Issues with these labels will never be considered stale
exemptLabels:
- pinned
- security
- 🐛bug
- 📖 documentation
- help wanted
- ✨enhancement
# Label to use when marking an issue as stale
staleLabel: wontfix
# Comment to post when marking an issue as stale. Set to `false` to disable

26
.github/workflows/deploy.yml vendored Normal file
View File

@ -0,0 +1,26 @@
name: 🚀 Deploy to PyPI
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Set up Python 3.8
uses: actions/setup-python@v1
with:
python-version: 3.8
- name: Build wheel and source tarball
run: |
pip install wheel
python setup.py sdist bdist_wheel
- name: Publish a Python distribution to PyPI
uses: pypa/gh-action-pypi-publish@v1.1.0
with:
user: __token__
password: ${{ secrets.pypi_password }}

22
.github/workflows/lint.yml vendored Normal file
View File

@ -0,0 +1,22 @@
name: Lint
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Set up Python 3.8
uses: actions/setup-python@v1
with:
python-version: 3.8
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install tox
- name: Run lint 💅
run: tox
env:
TOXENV: flake8

28
.github/workflows/tests.yml vendored Normal file
View File

@ -0,0 +1,28 @@
name: Tests
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
strategy:
max-parallel: 4
matrix:
django: ["2.2", "3.0"]
python-version: ["3.6", "3.7", "3.8"]
steps:
- uses: actions/checkout@v1
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install tox tox-gh-actions
- name: Test with tox
run: tox
env:
DJANGO: ${{ matrix.django }}
TOXENV: ${{ matrix.toxenv }}

View File

@ -1,65 +0,0 @@
language: python
cache: pip
dist: xenial
install:
- pip install tox tox-travis
script:
- tox
after_success:
- pip install coveralls
- coveralls
matrix:
fast_finish: true
include:
- python: 2.7
env: DJANGO=1.11
- python: 3.5
env: DJANGO=1.11
- python: 3.5
env: DJANGO=2.0
- python: 3.5
env: DJANGO=2.1
- python: 3.5
env: DJANGO=2.2
- python: 3.6
env: DJANGO=1.11
- python: 3.6
env: DJANGO=2.0
- python: 3.6
env: DJANGO=2.1
- python: 3.6
env: DJANGO=2.2
- python: 3.6
env: DJANGO=master
- python: 3.7
env: DJANGO=1.11
- python: 3.7
env: DJANGO=2.0
- python: 3.7
env: DJANGO=2.1
- python: 3.7
env: DJANGO=2.2
- python: 3.7
env: DJANGO=master
- python: 3.7
env: TOXENV=black,flake8
allow_failures:
- env: DJANGO=master
deploy:
provider: pypi
user: syrusakbary
on:
tags: true
password:
secure: kymIFCEPUbkgRqe2NAXkWfxMmGRfWvWBOP6LIXdVdkOOkm91fU7bndPGrAjos+/7gN0Org609ZmHSlVXNMJUWcsL2or/x5LcADJ4cZDe+79qynuoRb9xs1Ri4O4SBAuVMZxuVJvs8oUzT2R11ql5vASSMtXgbX+ZDGpmPRVZStkCuXgOc4LBhbPKyl3OFy7UQFPgAEmy3Yjh4ZSKzlXheK+S6mmr60+DCIjpaA0BWPxYK9FUE0qm7JJbHLUbwsUP/QMp5MmGjwFisXCNsIe686B7QKRaiOw62eJc2R7He8AuEC8T9OM4kRwDlecSn8mMpkoSB7QWtlJ+6XdLrJFPNvtrOfgfzS9/96Qrw9WlOslk68hMlhJeRb0s2YUD8tiV3UUkvbL1mfFoS4SI9U+rojS55KhUEJWHg1w7DjoOPoZmaIL2ChRupmvrFYNAGae1cxwG3Urh+t3wYlN3gpKsRDe5GOT7Wm2tr0ad3McCpDGUwSChX59BAJXe/MoLxkKScTrMyR8yMxHOF0b4zpVn5l7xB/o2Ik4zavx5q/0rGBMK2D+5d+gpQogKShoquTPsZUwO7sB5hYeH2hqGqpeGzZtb76E2zZYd18pJ0FsBudm5+KWjYdZ+vbtGrLxdTXJ1EEtzVXm0lscykTpqUucbXSa51dhStJvW2xEEz6p3rHo=
distributions: "sdist bdist_wheel"

View File

@ -1,12 +1,24 @@
Please read [UPGRADE-v2.0.md](https://github.com/graphql-python/graphene/blob/master/UPGRADE-v2.0.md) to learn how to upgrade to Graphene `2.0`.
---
# ![Graphene Logo](http://graphene-python.org/favicon.png) Graphene-Django [![Build Status](https://travis-ci.org/graphql-python/graphene-django.svg?branch=master)](https://travis-ci.org/graphql-python/graphene-django) [![PyPI version](https://badge.fury.io/py/graphene-django.svg)](https://badge.fury.io/py/graphene-django) [![Coverage Status](https://coveralls.io/repos/graphql-python/graphene-django/badge.svg?branch=master&service=github)](https://coveralls.io/github/graphql-python/graphene-django?branch=master)
# ![Graphene Logo](http://graphene-python.org/favicon.png) Graphene-Django
A [Django](https://www.djangoproject.com/) integration for [Graphene](http://graphene-python.org/).
[![travis][travis-image]][travis-url]
[![pypi][pypi-image]][pypi-url]
[![Anaconda-Server Badge][conda-image]][conda-url]
[![coveralls][coveralls-image]][coveralls-url]
[travis-image]: https://travis-ci.org/graphql-python/graphene-django.svg?branch=master&style=flat
[travis-url]: https://travis-ci.org/graphql-python/graphene-django
[pypi-image]: https://img.shields.io/pypi/v/graphene-django.svg?style=flat
[pypi-url]: https://pypi.org/project/graphene-django/
[coveralls-image]: https://coveralls.io/repos/github/graphql-python/graphene-django/badge.svg?branch=master
[coveralls-url]: https://coveralls.io/github/graphql-python/graphene-django?branch=master
[conda-image]: https://img.shields.io/conda/vn/conda-forge/graphene-django.svg
[conda-url]: https://anaconda.org/conda-forge/graphene-django
[💬 Join the community on Slack](https://join.slack.com/t/graphenetools/shared_invite/enQtOTE2MDQ1NTg4MDM1LTA4Nzk0MGU0NGEwNzUxZGNjNDQ4ZjAwNDJjMjY0OGE1ZDgxZTg4YjM2ZTc4MjE2ZTAzZjE2ZThhZTQzZTkyMmM)
## Documentation
[Visit the documentation to get started!](https://docs.graphene-python.org/projects/django/en/latest/)
@ -16,7 +28,7 @@ A [Django](https://www.djangoproject.com/) integration for [Graphene](http://gra
For installing graphene, just run this command in your shell
```bash
pip install "graphene-django>=2.0"
pip install "graphene-django>=3"
```
### Settings
@ -78,7 +90,7 @@ class Query(graphene.ObjectType):
schema = graphene.Schema(query=Query)
```
Then you can simply query the schema:
Then you can query the schema:
```python
query = '''
@ -101,3 +113,7 @@ To learn more check out the following [examples](examples/):
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md)
## Release Notes
* See [Releases page on github](https://github.com/graphql-python/graphene-django/releases)

View File

@ -23,7 +23,7 @@ For installing graphene, just run this command in your shell
.. code:: bash
pip install "graphene-django>=2.0"
pip install "graphene-django>=3"
Settings
~~~~~~~~

View File

@ -166,16 +166,7 @@ To restrict users from accessing the GraphQL API page the standard Django LoginR
After this, you can use the new ``PrivateGraphQLView`` in the project's URL Configuration file ``url.py``:
For Django 1.9 and below:
.. code:: python
urlpatterns = [
# some other urls
url(r'^graphql$', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
]
For Django 2.0 and above:
For Django 2.2 and above:
.. code:: python
@ -184,4 +175,4 @@ For Django 2.0 and above:
path('graphql', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
]
.. _LoginRequiredMixin: https://docs.djangoproject.com/en/1.10/topics/auth/default/#the-loginrequired-mixin
.. _LoginRequiredMixin: https://docs.djangoproject.com/en/dev/topics/auth/default/#the-loginrequired-mixin

View File

@ -60,18 +60,18 @@ source_suffix = ".rst"
master_doc = "index"
# General information about the project.
project = u"Graphene Django"
copyright = u"Graphene 2017"
author = u"Syrus Akbary"
project = "Graphene Django"
copyright = "Graphene 2017"
author = "Syrus Akbary"
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = u"1.0"
version = "1.0"
# The full version, including alpha/beta/rc tags.
release = u"1.0.dev"
release = "1.0.dev"
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
@ -276,7 +276,7 @@ latex_elements = {
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(master_doc, "Graphene.tex", u"Graphene Documentation", u"Syrus Akbary", "manual")
(master_doc, "Graphene.tex", "Graphene Documentation", "Syrus Akbary", "manual")
]
# The name of an image file (relative to this directory) to place at the top of
@ -317,7 +317,7 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, "graphene_django", u"Graphene Django Documentation", [author], 1)
(master_doc, "graphene_django", "Graphene Django Documentation", [author], 1)
]
# If true, show URL addresses after external links.
@ -334,7 +334,7 @@ texinfo_documents = [
(
master_doc,
"Graphene-Django",
u"Graphene Django Documentation",
"Graphene Django Documentation",
author,
"Graphene Django",
"One line description of project.",

12
docs/extra-types.rst Normal file
View File

@ -0,0 +1,12 @@
Extra Types
===========
Here are some libraries that provide common types for Django specific fields.
GeoDjango
---------
Use the graphene-gis_ library to add GeoDjango types to your Schema.
.. _graphene-gis: https://github.com/EverWinter23/graphene-gis

83
docs/fields.rst Normal file
View File

@ -0,0 +1,83 @@
Fields
======
Graphene-Django provides some useful fields to help integrate Django with your GraphQL
Schema.
DjangoListField
---------------
``DjangoListField`` allows you to define a list of :ref:`DjangoObjectType<queries-objecttypes>`'s. By default it will resolve the default queryset of the Django model.
.. code:: python
from graphene import ObjectType, Schema
from graphene_django import DjangoListField
class RecipeType(DjangoObjectType):
class Meta:
model = Recipe
fields = ("title", "instructions")
class Query(ObjectType):
recipes = DjangoListField(RecipeType)
schema = Schema(query=Query)
The above code results in the following schema definition:
.. code::
schema {
query: Query
}
type Query {
recipes: [RecipeType!]
}
type RecipeType {
title: String!
instructions: String!
}
Custom resolvers
****************
If your ``DjangoObjectType`` has defined a custom
:ref:`get_queryset<django-objecttype-get-queryset>` method, when resolving a
``DjangoListField`` it will be called with either the return of the field
resolver (if one is defined) or the default queryeset from the Django model.
For example the following schema will only resolve recipes which have been
published and have a title:
.. code:: python
from graphene import ObjectType, Schema
from graphene_django import DjangoListField
class RecipeType(DjangoObjectType):
class Meta:
model = Recipe
fields = ("title", "instructions")
@classmethod
def get_queryset(cls, queryset, info):
# Filter out recipes that have no title
return queryset.exclude(title__exact="")
class Query(ObjectType):
recipes = DjangoListField(RecipeType)
def resolve_recipes(parent, info):
# Only get recipes that have been published
return Recipe.objects.filter(published=True)
schema = Schema(query=Query)
DjangoConnectionField
---------------------
*TODO*

View File

@ -2,9 +2,8 @@ Filtering
=========
Graphene integrates with
`django-filter <https://django-filter.readthedocs.io/en/master/>`__ (2.x for
Python 3 or 1.x for Python 2) to provide filtering of results. See the `usage
documentation <https://django-filter.readthedocs.io/en/master/guide/usage.html#the-filter>`__
`django-filter <https://django-filter.readthedocs.io/en/master/>`__ to provide filtering of results.
See the `usage documentation <https://django-filter.readthedocs.io/en/master/guide/usage.html#the-filter>`__
for details on the format for ``filter_fields``.
This filtering is automatically available when implementing a ``relay.Node``.
@ -14,8 +13,17 @@ You will need to install it manually, which can be done as follows:
.. code:: bash
# You'll need to django-filter
# You'll need to install django-filter
pip install django-filter>=2
After installing ``django-filter`` you'll need to add the application in the ``settings.py`` file:
.. code:: python
INSTALLED_APPS = [
# ...
"django_filters",
]
Note: The techniques below are demoed in the `cookbook example
app <https://github.com/graphql-python/graphene-django/tree/master/examples/cookbook>`__.
@ -115,6 +123,15 @@ create your own ``FilterSet``. You can pass it directly as follows:
class AnimalFilter(django_filters.FilterSet):
# Do case-insensitive lookups on 'name'
name = django_filters.CharFilter(lookup_expr=['iexact'])
# Allow multiple genera to be selected at once
genera = django_filters.MultipleChoiceFilter(
field_name='genus',
choices=(
('Canis', 'Canis'),
('Panthera', 'Panthera'),
('Seahorse', 'Seahorse')
)
)
class Meta:
model = Animal
@ -127,6 +144,22 @@ create your own ``FilterSet``. You can pass it directly as follows:
all_animals = DjangoFilterConnectionField(AnimalNode,
filterset_class=AnimalFilter)
If you were interested in selecting all dogs and cats, you might query as follows:
.. code::
query {
allAnimals(genera: ["Canis", "Panthera"]) {
edges {
node {
id,
name
}
}
}
}
You can also specify the ``FilterSet`` class using the ``filterset_class``
parameter when defining your ``DjangoObjectType``, however, this can't be used
in unison with the ``filter_fields`` parameter:
@ -154,6 +187,7 @@ in unison with the ``filter_fields`` parameter:
animal = relay.Node.Field(AnimalNode)
all_animals = DjangoFilterConnectionField(AnimalNode)
The context argument is passed on as the `request argument <http://django-filter.readthedocs.io/en/master/guide/usage.html#request-based-filtering>`__
in a ``django_filters.FilterSet`` instance. You can use this to customize your
filters to be context-dependent. We could modify the ``AnimalFilter`` above to

View File

@ -25,6 +25,8 @@ For more advanced use, check out the Relay tutorial.
tutorial-relay
schema
queries
fields
extra-types
mutations
filtering
authorization

View File

@ -8,7 +8,7 @@ Requirements
Graphene-Django currently supports the following versions of Django:
* Django 2.X
* >= Django 2.2
Installation
------------
@ -32,6 +32,8 @@ Add ``graphene_django`` to the ``INSTALLED_APPS`` in the ``settings.py`` file of
We need to add a ``graphql`` URL to the ``urls.py`` of your Django project:
For Django 2.2 and above:
.. code:: python
from django.urls import path
@ -66,4 +68,26 @@ The most basic ``schema.py`` looks like this:
schema = graphene.Schema(query=Query)
To learn how to extend the schema object for your project, read the basic tutorial.
To learn how to extend the schema object for your project, read the basic tutorial.
CSRF exempt
-----------
If have enabled `CSRF protection <https://docs.djangoproject.com/en/3.0/ref/csrf/>`_ in your Django app
you will find that it prevents your API clients from POSTing to the ``graphql`` endpoint. You can either
update your API client to pass the CSRF token with each request (the Django docs have a guide on how to do that: https://docs.djangoproject.com/en/3.0/ref/csrf/#ajax) or you can exempt your Graphql endpoint from CSRF protection by wrapping the ``GraphQLView`` with the ``csrf_exempt``
decorator:
.. code:: python
# urls.py
from django.urls import path
from django.views.decorators.csrf import csrf_exempt
from graphene_django.views import GraphQLView
urlpatterns = [
# ...
path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True))),
]

View File

@ -29,6 +29,20 @@ you're ready to use Relay with Graphene GraphQL implementation.
The schema file is sorted to create a reproducible canonical representation.
GraphQL SDL Representation
--------------------------
The schema can also be exported as a GraphQL SDL file by changing the file
extension :
.. code:: bash
./manage.py graphql_schema --schema tutorial.quickstart.schema --out schema.graphql
When exporting the schema as a ``.graphql`` file the ``--indent`` option is
ignored.
Advanced Usage
--------------
@ -44,7 +58,8 @@ specify the parameters in your settings.py:
GRAPHENE = {
'SCHEMA': 'tutorial.quickstart.schema',
'SCHEMA_OUTPUT': 'data/schema.json' # defaults to schema.json
'SCHEMA_OUTPUT': 'data/schema.json', # defaults to schema.json,
'SCHEMA_INDENT': 2, # Defaults to None (displays all data on a single line)
}

View File

@ -1,3 +1,5 @@
.. _queries-objecttypes:
Queries & ObjectTypes
=====================
@ -205,6 +207,8 @@ need to create the most basic class for this to work:
class Meta:
model = Category
.. _django-objecttype-get-queryset:
Default QuerySet
-----------------
@ -282,6 +286,13 @@ of Django's ``HTTPRequest`` in your resolve methods, such as checking for authen
return Question.objects.none()
DjangoObjectTypes
~~~~~~~~~~~~~~~~~
A Resolver that maps to a defined `DjangoObjectType` should only use methods that return a queryset.
Queryset methods like `values` will return dictionaries, use `defer` instead.
Plain ObjectTypes
-----------------

58
docs/schema.py Normal file
View File

@ -0,0 +1,58 @@
import graphene
from graphene_django.types import DjangoObjectType
from cookbook.ingredients.models import Category, Ingredient
class CategoryType(DjangoObjectType):
class Meta:
model = Category
class IngredientType(DjangoObjectType):
class Meta:
model = Ingredient
class Query(object):
category = graphene.Field(CategoryType,
id=graphene.Int(),
name=graphene.String())
all_categories = graphene.List(CategoryType)
ingredient = graphene.Field(IngredientType,
id=graphene.Int(),
name=graphene.String())
all_ingredients = graphene.List(IngredientType)
def resolve_all_categories(self, info, **kwargs):
return Category.objects.all()
def resolve_all_ingredients(self, info, **kwargs):
return Ingredient.objects.all()
def resolve_category(self, info, **kwargs):
id = kwargs.get('id')
name = kwargs.get('name')
if id is not None:
return Category.objects.get(pk=id)
if name is not None:
return Category.objects.get(name=name)
return None
def resolve_ingredient(self, info, **kwargs):
id = kwargs.get('id')
name = kwargs.get('name')
if id is not None:
return Ingredient.objects.get(pk=id)
if name is not None:
return Ingredient.objects.get(name=name)
return None

View File

@ -140,3 +140,33 @@ Default: ``False``
# 'messages': ['This field is required.'],
# }
# ]
``DJANGO_CHOICE_FIELD_ENUM_V3_NAMING``
--------------------------------------
Set to ``True`` to use the new naming format for the auto generated Enum types from Django choice fields. The new format looks like this: ``{app_label}{object_name}{field_name}Choices``
Default: ``False``
``DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME``
--------------------------------------
Define the path of a function that takes the Django choice field and returns a string to completely customise the naming for the Enum type.
If set to a function then the ``DJANGO_CHOICE_FIELD_ENUM_V3_NAMING`` setting is ignored.
Default: ``None``
.. code:: python
# myapp.utils
def enum_naming(field):
if isinstance(field.model, User):
return f"CustomUserEnum{field.name.title()}"
return f"CustomEnum{field.name.title()}"
GRAPHENE = {
'DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME': "myapp.utils.enum_naming"
}

View File

@ -3,6 +3,8 @@ Testing API calls with django
If you want to unittest your API calls derive your test case from the class `GraphQLTestCase`.
Your endpoint is set through the `GRAPHQL_URL` attribute on `GraphQLTestCase`. The default endpoint is `GRAPHQL_URL = "/graphql/"`.
Usage:
.. code:: python

View File

@ -286,7 +286,7 @@ from the command line.
$ python ./manage.py runserver
Performing system checks...
Django version 1.9, using settings 'cookbook.settings'
Django version 1.11, using settings 'cookbook.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
@ -417,67 +417,10 @@ Getting single objects
So far, we have been able to fetch list of objects and follow relation. But what about single objects?
We can update our schema to support that, by adding new query for ``ingredient`` and ``category`` and adding arguments, so we can query for specific objects.
Add the **Highlighted** lines to ``cookbook/ingredients/schema.py``
.. code:: python
import graphene
from graphene_django.types import DjangoObjectType
from cookbook.ingredients.models import Category, Ingredient
class CategoryType(DjangoObjectType):
class Meta:
model = Category
class IngredientType(DjangoObjectType):
class Meta:
model = Ingredient
class Query(object):
category = graphene.Field(CategoryType,
id=graphene.Int(),
name=graphene.String())
all_categories = graphene.List(CategoryType)
ingredient = graphene.Field(IngredientType,
id=graphene.Int(),
name=graphene.String())
all_ingredients = graphene.List(IngredientType)
def resolve_all_categories(self, info, **kwargs):
return Category.objects.all()
def resolve_all_ingredients(self, info, **kwargs):
return Ingredient.objects.all()
def resolve_category(self, info, **kwargs):
id = kwargs.get('id')
name = kwargs.get('name')
if id is not None:
return Category.objects.get(pk=id)
if name is not None:
return Category.objects.get(name=name)
return None
def resolve_ingredient(self, info, **kwargs):
id = kwargs.get('id')
name = kwargs.get('name')
if id is not None:
return Ingredient.objects.get(pk=id)
if name is not None:
return Ingredient.objects.get(name=name)
return None
.. literalinclude:: schema.py
:emphasize-lines: 19-21,25-27,36-58
Now, with the code in place, we can query for single objects.

View File

@ -277,7 +277,7 @@ from the command line.
$ python ./manage.py runserver
Performing system checks...
Django version 1.9, using settings 'cookbook.settings'
Django version 1.11, using settings 'cookbook.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

View File

@ -1,4 +1,4 @@
graphene
graphene-django
graphql-core>=2.1rc1
django==2.2.4
graphene>=2.1,<3
graphene-django>=2.1,<3
graphql-core>=2.1,<3
django==3.0.3

View File

@ -41,6 +41,7 @@ INSTALLED_APPS = [
"graphene_django",
"cookbook.ingredients.apps.IngredientsConfig",
"cookbook.recipes.apps.RecipesConfig",
"django_filters",
]
MIDDLEWARE = [

View File

@ -1,5 +1,5 @@
graphene
graphene-django
graphql-core>=2.1rc1
django==2.2.4
graphene>=2.1,<3
graphene-django>=2.1,<3
graphql-core>=2.1,<3
django==3.0.3
django-filter>=2

View File

@ -1,6 +1,11 @@
from .fields import DjangoConnectionField, DjangoListField
from .types import DjangoObjectType
from .fields import DjangoConnectionField
__version__ = "2.6.0"
__version__ = "3.0.0b1"
__all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"]
__all__ = [
"__version__",
"DjangoObjectType",
"DjangoListField",
"DjangoConnectionField",
]

View File

@ -0,0 +1,18 @@
import pytest
from graphene_django.settings import graphene_settings as gsettings
from .registry import reset_global_registry
@pytest.fixture(autouse=True)
def reset_registry_fixture(db):
yield None
reset_global_registry()
@pytest.fixture()
def graphene_settings():
settings = dict(gsettings.__dict__)
yield gsettings
gsettings.__dict__ = settings

View File

@ -1,10 +1,16 @@
from collections import OrderedDict
from django.db import models
from django.utils.encoding import force_text
from functools import singledispatch
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 graphene import (
ID,
UUID,
Boolean,
Date,
DateTime,
Dynamic,
Enum,
Field,
@ -13,27 +19,23 @@ from graphene import (
List,
NonNull,
String,
UUID,
DateTime,
Date,
Time,
)
from graphene.types.json import JSONString
from graphene.utils.str_converters import to_camel_case, to_const
from graphql import assert_valid_name
from graphql import GraphQLError, assert_valid_name
from graphql.pyutils import register_description
from .compat import ArrayField, HStoreField, JSONField, RangeField
from .fields import DjangoListField, DjangoConnectionField
from .utils import import_single_dispatch
singledispatch = import_single_dispatch()
from .fields import DjangoConnectionField, DjangoListField
from .settings import graphene_settings
def convert_choice_name(name):
name = to_const(force_text(name))
name = to_const(force_str(name))
try:
assert_valid_name(name)
except AssertionError:
except GraphQLError:
name = "A_%s" % name
return name
@ -51,7 +53,9 @@ def get_choices(choices):
while name in converted_names:
name += "_" + str(len(converted_names))
converted_names.append(name)
description = help_text
description = str(
help_text
) # TODO: translatable description: https://github.com/graphql-python/graphql-core-next/issues/58
yield name, value, description
@ -63,11 +67,36 @@ def convert_choices_to_named_enum_with_descriptions(name, choices):
class EnumWithDescriptionsType(object):
@property
def description(self):
return named_choices_descriptions[self.name]
return str(named_choices_descriptions[self.name])
return Enum(name, list(named_choices), type=EnumWithDescriptionsType)
def generate_enum_name(django_model_meta, field):
if graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME:
# Try and import custom function
custom_func = import_string(
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME
)
name = custom_func(field)
elif graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING is True:
name = "{app_label}{object_name}{field_name}Choices".format(
app_label=to_camel_case(django_model_meta.app_label.title()),
object_name=django_model_meta.object_name,
field_name=to_camel_case(field.name.title()),
)
else:
name = to_camel_case("{}_{}".format(django_model_meta.object_name, field.name))
return name
def convert_choice_field_to_enum(field, name=None):
if name is None:
name = generate_enum_name(field.model._meta, field)
choices = field.choices
return convert_choices_to_named_enum_with_descriptions(name, choices)
def convert_django_field_with_choices(
field, registry=None, convert_choices_to_enum=True
):
@ -77,9 +106,7 @@ def convert_django_field_with_choices(
return converted
choices = getattr(field, "choices", None)
if choices and convert_choices_to_enum:
meta = field.model._meta
name = to_camel_case("{}_{}".format(meta.object_name, field.name))
enum = convert_choices_to_named_enum_with_descriptions(name, choices)
enum = convert_choice_field_to_enum(field)
required = not (field.blank or field.null)
converted = enum(description=field.help_text, required=required)
else:
@ -127,13 +154,9 @@ def convert_field_to_int(field, registry=None):
return Int(description=field.help_text, required=not field.null)
@convert_django_field.register(models.NullBooleanField)
@convert_django_field.register(models.BooleanField)
def convert_field_to_boolean(field, registry=None):
return NonNull(Boolean, description=field.help_text)
@convert_django_field.register(models.NullBooleanField)
def convert_field_to_nullboolean(field, registry=None):
return Boolean(description=field.help_text, required=not field.null)
@ -252,3 +275,8 @@ def convert_postgres_range_to_string(field, registry=None):
if not isinstance(inner_type, (List, NonNull)):
inner_type = type(inner_type)
return List(inner_type, description=field.help_text, required=not field.null)
# Register Django lazy()-wrapped values as GraphQL description/help_text.
# This is needed for using lazy translations, see https://github.com/graphql-python/graphql-core-next/issues/58.
register_description(Promise)

View File

@ -17,7 +17,7 @@ class DjangoDebugContext(object):
if not self.debug_promise:
self.debug_promise = Promise.all(self.promises)
self.promises = []
return self.debug_promise.then(self.on_resolve_all_promises)
return self.debug_promise.then(self.on_resolve_all_promises).get()
def on_resolve_all_promises(self, values):
if self.promises:

View File

@ -5,8 +5,7 @@ import json
from threading import local
from time import time
from django.utils import six
from django.utils.encoding import force_text
from django.utils.encoding import force_str
from .types import DjangoDebugSQL
@ -77,8 +76,8 @@ class NormalCursorWrapper(object):
self.logger = logger
def _quote_expr(self, element):
if isinstance(element, six.string_types):
return "'%s'" % force_text(element).replace("'", "''")
if isinstance(element, str):
return "'%s'" % force_str(element).replace("'", "''")
else:
return repr(element)
@ -91,7 +90,7 @@ class NormalCursorWrapper(object):
def _decode(self, param):
try:
return force_text(param, strings_only=True)
return force_str(param, strings_only=True)
except UnicodeDecodeError:
return "(encoded string)"

View File

@ -1,5 +1,3 @@
import pytest
import graphene
from graphene.relay import Node
from graphene_django import DjangoConnectionField, DjangoObjectType
@ -13,11 +11,6 @@ class context(object):
pass
# from examples.starwars_django.models import Character
pytestmark = pytest.mark.django_db
def test_should_query_field():
r1 = Reporter(last_name="ABA")
r1.save()
@ -75,7 +68,7 @@ def test_should_query_nested_field():
class Query(graphene.ObjectType):
reporter = graphene.Field(ReporterType)
debug = graphene.Field(DjangoDebug, name="__debug")
debug = graphene.Field(DjangoDebug, name="_debug")
def resolve_reporter(self, info, **args):
return Reporter.objects.first()
@ -89,7 +82,7 @@ def test_should_query_nested_field():
pets { edges { node { lastName } } }
} } }
}
__debug {
_debug {
sql {
rawSql
}
@ -117,12 +110,12 @@ def test_should_query_nested_field():
)
assert not result.errors
query = str(Reporter.objects.order_by("pk")[:1].query)
assert result.data["__debug"]["sql"][0]["rawSql"] == query
assert "COUNT" in result.data["__debug"]["sql"][1]["rawSql"]
assert "tests_reporter_pets" in result.data["__debug"]["sql"][2]["rawSql"]
assert "COUNT" in result.data["__debug"]["sql"][3]["rawSql"]
assert "tests_reporter_pets" in result.data["__debug"]["sql"][4]["rawSql"]
assert len(result.data["__debug"]["sql"]) == 5
assert result.data["_debug"]["sql"][0]["rawSql"] == query
assert "COUNT" in result.data["_debug"]["sql"][1]["rawSql"]
assert "tests_reporter_pets" in result.data["_debug"]["sql"][2]["rawSql"]
assert "COUNT" in result.data["_debug"]["sql"][3]["rawSql"]
assert "tests_reporter_pets" in result.data["_debug"]["sql"][4]["rawSql"]
assert len(result.data["_debug"]["sql"]) == 5
assert result.data["reporter"] == expected["reporter"]

View File

@ -1,11 +1,12 @@
from functools import partial
from django.db.models.query import QuerySet
from graphql_relay.connection.arrayconnection import connection_from_list_slice
from graphql_relay.connection.arrayconnection import connection_from_array_slice
from promise import Promise
from graphene import NonNull
from graphene.relay import ConnectionField, PageInfo
from graphene.relay import ConnectionField
from graphene.relay.connection import connection_adapter, page_info_adapter
from graphene.types import Field, List
from .settings import graphene_settings
@ -19,30 +20,39 @@ class DjangoListField(Field):
if isinstance(_type, NonNull):
_type = _type.of_type
assert issubclass(
_type, DjangoObjectType
), "DjangoListField only accepts DjangoObjectType types"
# Django would never return a Set of None vvvvvvv
super(DjangoListField, self).__init__(List(NonNull(_type)), *args, **kwargs)
assert issubclass(
self._underlying_type, DjangoObjectType
), "DjangoListField only accepts DjangoObjectType types"
@property
def _underlying_type(self):
_type = self._type
while hasattr(_type, "of_type"):
_type = _type.of_type
return _type
@property
def model(self):
_type = self.type.of_type
if isinstance(_type, NonNull):
_type = _type.of_type
return _type._meta.model
return self._underlying_type._meta.model
def get_default_queryset(self):
return self.model._default_manager.get_queryset()
@staticmethod
def list_resolver(django_object_type, resolver, root, info, **args):
def list_resolver(
django_object_type, resolver, default_queryset, root, info, **args
):
queryset = maybe_queryset(resolver(root, info, **args))
if queryset is None:
# Default to Django Model queryset
# N.B. This happens if DjangoListField is used in the top level Query object
model = django_object_type._meta.model
queryset = maybe_queryset(
django_object_type.get_queryset(model.objects, info)
)
queryset = default_queryset
if isinstance(queryset, QuerySet):
# Pass queryset to the DjangoObjectType get_queryset method
queryset = maybe_queryset(django_object_type.get_queryset(queryset, info))
return queryset
def get_resolver(self, parent_resolver):
@ -50,7 +60,12 @@ class DjangoListField(Field):
if isinstance(_type, NonNull):
_type = _type.of_type
django_object_type = _type.of_type.of_type
return partial(self.list_resolver, django_object_type, parent_resolver)
return partial(
self.list_resolver,
django_object_type,
parent_resolver,
self.get_default_queryset(),
)
class DjangoConnectionField(ConnectionField):
@ -108,37 +123,25 @@ class DjangoConnectionField(ConnectionField):
@classmethod
def resolve_queryset(cls, connection, queryset, info, args):
# queryset is the resolved iterable from ObjectType
return connection._meta.node.get_queryset(queryset, info)
@classmethod
def merge_querysets(cls, default_queryset, queryset):
if default_queryset.query.distinct and not queryset.query.distinct:
queryset = queryset.distinct()
elif queryset.query.distinct and not default_queryset.query.distinct:
default_queryset = default_queryset.distinct()
return queryset & default_queryset
@classmethod
def resolve_connection(cls, connection, default_manager, args, iterable):
if iterable is None:
iterable = default_manager
def resolve_connection(cls, connection, args, iterable):
iterable = maybe_queryset(iterable)
if isinstance(iterable, QuerySet):
if iterable.model.objects is not default_manager:
default_queryset = maybe_queryset(default_manager)
iterable = cls.merge_querysets(default_queryset, iterable)
_len = iterable.count()
else:
_len = len(iterable)
connection = connection_from_list_slice(
connection = connection_from_array_slice(
iterable,
args,
slice_start=0,
list_length=_len,
list_slice_length=_len,
connection_type=connection,
array_length=_len,
array_slice_length=_len,
connection_type=partial(connection_adapter, connection),
edge_type=connection.Edge,
pageinfo_type=PageInfo,
page_info_type=page_info_adapter,
)
connection.iterable = iterable
connection.length = _len
@ -150,6 +153,7 @@ class DjangoConnectionField(ConnectionField):
resolver,
connection,
default_manager,
queryset_resolver,
max_limit,
enforce_first_or_last,
root,
@ -177,9 +181,15 @@ class DjangoConnectionField(ConnectionField):
).format(last, info.field_name, max_limit)
args["last"] = min(last, max_limit)
# eventually leads to DjangoObjectType's get_queryset (accepts queryset)
# or a resolve_foo (does not accept queryset)
iterable = resolver(root, info, **args)
queryset = cls.resolve_queryset(connection, default_manager, info, args)
on_resolve = partial(cls.resolve_connection, connection, queryset, args)
if iterable is None:
iterable = default_manager
# thus the iterable gets refiltered by resolve_queryset
# but iterable might be promise
iterable = queryset_resolver(connection, iterable, info, args)
on_resolve = partial(cls.resolve_connection, connection, args)
if Promise.is_thenable(iterable):
return Promise.resolve(iterable).then(on_resolve)
@ -192,6 +202,10 @@ class DjangoConnectionField(ConnectionField):
parent_resolver,
self.connection_type,
self.get_manager(),
self.get_queryset_resolver(),
self.max_limit,
self.enforce_first_or_last,
)
def get_queryset_resolver(self):
return self.resolve_queryset

View File

@ -52,69 +52,18 @@ class DjangoFilterConnectionField(DjangoConnectionField):
return get_filtering_args_from_filterset(self.filterset_class, self.node_type)
@classmethod
def merge_querysets(cls, default_queryset, queryset):
# There could be the case where the default queryset (returned from the filterclass)
# and the resolver queryset have some limits on it.
# We only would be able to apply one of those, but not both
# at the same time.
# See related PR: https://github.com/graphql-python/graphene-django/pull/126
assert not (
default_queryset.query.low_mark and queryset.query.low_mark
), "Received two sliced querysets (low mark) in the connection, please slice only in one."
assert not (
default_queryset.query.high_mark and queryset.query.high_mark
), "Received two sliced querysets (high mark) in the connection, please slice only in one."
low = default_queryset.query.low_mark or queryset.query.low_mark
high = default_queryset.query.high_mark or queryset.query.high_mark
default_queryset.query.clear_limits()
queryset = super(DjangoFilterConnectionField, cls).merge_querysets(
default_queryset, queryset
)
queryset.query.set_limits(low, high)
return queryset
@classmethod
def connection_resolver(
cls,
resolver,
connection,
default_manager,
max_limit,
enforce_first_or_last,
filterset_class,
filtering_args,
root,
info,
**args
def resolve_queryset(
cls, connection, iterable, info, args, filtering_args, filterset_class
):
qs = super(DjangoFilterConnectionField, cls).resolve_queryset(
connection, iterable, info, args
)
filter_kwargs = {k: v for k, v in args.items() if k in filtering_args}
qs = filterset_class(
data=filter_kwargs,
queryset=default_manager.get_queryset(),
request=info.context,
).qs
return filterset_class(data=filter_kwargs, queryset=qs, request=info.context).qs
return super(DjangoFilterConnectionField, cls).connection_resolver(
resolver,
connection,
qs,
max_limit,
enforce_first_or_last,
root,
info,
**args
)
def get_resolver(self, parent_resolver):
def get_queryset_resolver(self):
return partial(
self.connection_resolver,
parent_resolver,
self.connection_type,
self.get_manager(),
self.max_limit,
self.enforce_first_or_last,
self.filterset_class,
self.filtering_args,
self.resolve_queryset,
filterset_class=self.filterset_class,
filtering_args=self.filtering_args,
)

View File

@ -1,7 +1,7 @@
import itertools
from django.db import models
from django_filters import Filter, MultipleChoiceFilter, VERSION
from django_filters import Filter, MultipleChoiceFilter
from django_filters.filterset import BaseFilterSet, FilterSet
from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS
@ -50,36 +50,6 @@ class GrapheneFilterSetMixin(BaseFilterSet):
)
# To support a Django 1.11 + Python 2.7 combination django-filter must be
# < 2.x.x. To support the earlier version of django-filter, the
# filter_for_reverse_field method must be present on GrapheneFilterSetMixin and
# must not be present for later versions of django-filter.
if VERSION[0] < 2:
from django.utils.text import capfirst
class GrapheneFilterSetMixinPython2(GrapheneFilterSetMixin):
@classmethod
def filter_for_reverse_field(cls, f, name):
"""Handles retrieving filters for reverse relationships
We override the default implementation so that we can handle
Global IDs (the default implementation expects database
primary keys)
"""
try:
rel = f.field.remote_field
except AttributeError:
rel = f.field.rel
default = {"name": name, "label": capfirst(rel.related_name)}
if rel.multiple:
# For to-many relationships
return GlobalIDMultipleChoiceFilter(**default)
else:
# For to-one relationships
return GlobalIDFilter(**default)
GrapheneFilterSetMixin = GrapheneFilterSetMixinPython2
def setup_filterset(filterset_class):
""" Wrap a provided filterset in Graphene-specific functionality
"""

View File

@ -35,9 +35,6 @@ else:
)
)
pytestmark.append(pytest.mark.django_db)
if DJANGO_FILTER_INSTALLED:
class ArticleNode(DjangoObjectType):
@ -180,7 +177,7 @@ def test_filter_shortcut_filterset_context():
}
"""
schema = Schema(query=Query)
result = schema.execute(query, context=context())
result = schema.execute(query, context_value=context())
assert not result.errors
assert len(result.data["contextArticles"]["edges"]) == 1
@ -608,58 +605,6 @@ def test_should_query_filter_node_limit():
assert result.data == expected
def test_should_query_filter_node_double_limit_raises():
class ReporterFilter(FilterSet):
limit = NumberFilter(method="filter_limit")
def filter_limit(self, queryset, name, value):
return queryset[:value]
class Meta:
model = Reporter
fields = ["first_name"]
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
class Query(ObjectType):
all_reporters = DjangoFilterConnectionField(
ReporterType, filterset_class=ReporterFilter
)
def resolve_all_reporters(self, info, **args):
return Reporter.objects.order_by("a_choice")[:2]
Reporter.objects.create(
first_name="Bob", last_name="Doe", email="bobdoe@example.com", a_choice=2
)
Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
schema = Schema(query=Query)
query = """
query NodeFilteringQuery {
allReporters(limit: 1) {
edges {
node {
id
firstName
}
}
}
}
"""
result = schema.execute(query)
assert len(result.errors) == 1
assert str(result.errors[0]) == (
"Received two sliced querysets (high mark) in the connection, please slice only in one."
)
def test_order_by_is_perserved():
class ReporterType(DjangoObjectType):
class Meta:
@ -721,7 +666,7 @@ def test_order_by_is_perserved():
assert reverse_result.data == reverse_expected
def test_annotation_is_perserved():
def test_annotation_is_preserved():
class ReporterType(DjangoObjectType):
full_name = String()
@ -766,6 +711,86 @@ def test_annotation_is_perserved():
assert result.data == expected
def test_annotation_with_only():
class ReporterType(DjangoObjectType):
full_name = String()
class Meta:
model = Reporter
interfaces = (Node,)
filter_fields = ()
class Query(ObjectType):
all_reporters = DjangoFilterConnectionField(ReporterType)
def resolve_all_reporters(self, info, **args):
return Reporter.objects.only("first_name", "last_name").annotate(
full_name=Concat(
"first_name", Value(" "), "last_name", output_field=TextField()
)
)
Reporter.objects.create(first_name="John", last_name="Doe")
schema = Schema(query=Query)
query = """
query NodeFilteringQuery {
allReporters(first: 1) {
edges {
node {
fullName
}
}
}
}
"""
expected = {"allReporters": {"edges": [{"node": {"fullName": "John Doe"}}]}}
result = schema.execute(query)
assert not result.errors
assert result.data == expected
def test_node_get_queryset_is_called():
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
filter_fields = ()
@classmethod
def get_queryset(cls, queryset, info):
return queryset.filter(first_name="b")
class Query(ObjectType):
all_reporters = DjangoFilterConnectionField(
ReporterType, reverse_order=Boolean()
)
Reporter.objects.create(first_name="b")
Reporter.objects.create(first_name="a")
schema = Schema(query=Query)
query = """
query NodeFilteringQuery {
allReporters(first: 10) {
edges {
node {
firstName
}
}
}
}
"""
expected = {"allReporters": {"edges": [{"node": {"firstName": "b"}}]}}
result = schema.execute(query)
assert not result.errors
assert result.data == expected
def test_integer_field_filter_type():
class PetType(DjangoObjectType):
class Meta:
@ -781,38 +806,56 @@ def test_integer_field_filter_type():
assert str(schema) == dedent(
"""\
schema {
query: Query
}
interface Node {
id: ID!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type PetType implements Node {
age: Int!
id: ID!
type Query {
pets(before: String = null, after: String = null, first: Int = null, last: Int = null, age: Int = null): PetTypeConnection
}
type PetTypeConnection {
\"""Pagination data for this connection.\"""
pageInfo: PageInfo!
\"""Contains the nodes in this connection.\"""
edges: [PetTypeEdge]!
}
\"""
The Relay compliant `PageInfo` type, containing data necessary to paginate this connection.
\"""
type PageInfo {
\"""When paginating forwards, are there more items?\"""
hasNextPage: Boolean!
\"""When paginating backwards, are there more items?\"""
hasPreviousPage: Boolean!
\"""When paginating backwards, the cursor to continue.\"""
startCursor: String
\"""When paginating forwards, the cursor to continue.\"""
endCursor: String
}
\"""A Relay edge containing a `PetType` and its cursor.\"""
type PetTypeEdge {
\"""The item at the end of the edge\"""
node: PetType
\"""A cursor for use in pagination\"""
cursor: String!
}
type Query {
pets(before: String, after: String, first: Int, last: Int, age: Int): PetTypeConnection
type PetType implements Node {
\"""\"""
age: Int!
\"""The ID of the object\"""
id: ID!
}
\"""An object with an ID\"""
interface Node {
\"""The ID of the object\"""
id: ID!
}
"""
)
@ -833,40 +876,58 @@ def test_other_filter_types():
assert str(schema) == dedent(
"""\
schema {
query: Query
}
interface Node {
id: ID!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type PetType implements Node {
age: Int!
id: ID!
type Query {
pets(before: String = null, after: String = null, first: Int = null, last: Int = null, age: Int = null, age_Isnull: Boolean = null, age_Lt: Int = null): PetTypeConnection
}
type PetTypeConnection {
\"""Pagination data for this connection.\"""
pageInfo: PageInfo!
\"""Contains the nodes in this connection.\"""
edges: [PetTypeEdge]!
}
\"""
The Relay compliant `PageInfo` type, containing data necessary to paginate this connection.
\"""
type PageInfo {
\"""When paginating forwards, are there more items?\"""
hasNextPage: Boolean!
\"""When paginating backwards, are there more items?\"""
hasPreviousPage: Boolean!
\"""When paginating backwards, the cursor to continue.\"""
startCursor: String
\"""When paginating forwards, the cursor to continue.\"""
endCursor: String
}
\"""A Relay edge containing a `PetType` and its cursor.\"""
type PetTypeEdge {
\"""The item at the end of the edge\"""
node: PetType
\"""A cursor for use in pagination\"""
cursor: String!
}
type Query {
pets(before: String, after: String, first: Int, last: Int, age: Int, age_Isnull: Boolean, age_Lt: Int): PetTypeConnection
type PetType implements Node {
\"""\"""
age: Int!
\"""The ID of the object\"""
id: ID!
}
"""
\"""An object with an ID\"""
interface Node {
\"""The ID of the object\"""
id: ID!
}
"""
)

View File

@ -1,5 +1,4 @@
import six
from django_filters.utils import get_model_field
from .filterset import custom_filterset_factory, setup_filterset
@ -12,28 +11,18 @@ def get_filtering_args_from_filterset(filterset_class, type):
args = {}
model = filterset_class._meta.model
for name, filter_field in six.iteritems(filterset_class.base_filters):
for name, filter_field in filterset_class.base_filters.items():
form_field = None
if name in filterset_class.declared_filters:
form_field = filter_field.field
else:
try:
field_name, filter_type = name.rsplit("__", 1)
except ValueError:
field_name = name
filter_type = None
# If the filter type is `isnull` then use the filter provided by
# DjangoFilter (a BooleanFilter).
# Otherwise try and get a filter based on the actual model field
if filter_type != "isnull" and hasattr(model, field_name):
model_field = model._meta.get_field(field_name)
if hasattr(model_field, "formfield"):
form_field = model_field.formfield(
required=filter_field.extra.get("required", False)
)
model_field = get_model_field(model, filter_field.field_name)
filter_type = filter_field.lookup_expr
if filter_type != "isnull" and hasattr(model_field, "formfield"):
form_field = model_field.formfield(
required=filter_field.extra.get("required", False)
)
# Fallback to field defined on filter if we can't get it from the
# model field

View File

@ -1,13 +1,11 @@
from functools import singledispatch
from django import forms
from django.core.exceptions import ImproperlyConfigured
from graphene import ID, Boolean, Float, Int, List, String, UUID, Date, DateTime, Time
from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField
from ..utils import import_single_dispatch
singledispatch = import_single_dispatch()
@singledispatch
@ -57,9 +55,14 @@ def convert_form_field_to_float(field):
return Float(description=field.help_text, required=field.required)
@convert_form_field.register(forms.MultipleChoiceField)
def convert_form_field_to_string_list(field):
return List(String, description=field.help_text, required=field.required)
@convert_form_field.register(forms.ModelMultipleChoiceField)
@convert_form_field.register(GlobalIDMultipleChoiceField)
def convert_form_field_to_list(field):
def convert_form_field_to_id_list(field):
return List(ID, required=field.required)

View File

@ -2,7 +2,7 @@ import binascii
from django.core.exceptions import ValidationError
from django.forms import CharField, Field, MultipleChoiceField
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
from graphql_relay import from_global_id

View File

@ -47,7 +47,7 @@ class BaseDjangoFormMutation(ClientIDMutation):
else:
errors = ErrorType.from_errors(form.errors)
return cls(errors=errors)
return cls(errors=errors, **form.data)
@classmethod
def get_form(cls, root, info, **input):
@ -66,28 +66,6 @@ class BaseDjangoFormMutation(ClientIDMutation):
return kwargs
# class DjangoFormInputObjectTypeOptions(InputObjectTypeOptions):
# form_class = None
# class DjangoFormInputObjectType(InputObjectType):
# class Meta:
# abstract = True
# @classmethod
# def __init_subclass_with_meta__(cls, form_class=None,
# only_fields=(), exclude_fields=(), _meta=None, **options):
# if not _meta:
# _meta = DjangoFormInputObjectTypeOptions(cls)
# assert isinstance(form_class, forms.Form), (
# 'form_class must be an instance of django.forms.Form'
# )
# _meta.form_class = form_class
# form = form_class()
# fields = fields_for_form(form, only_fields, exclude_fields)
# super(DjangoFormInputObjectType, cls).__init_subclass_with_meta__(_meta=_meta, fields=fields, **options)
class DjangoFormMutationOptions(MutationOptions):
form_class = None
@ -122,7 +100,7 @@ class DjangoFormMutation(BaseDjangoFormMutation):
@classmethod
def perform_mutate(cls, form, info):
form.save()
return cls(errors=[])
return cls(errors=[], **form.cleaned_data)
class DjangoModelDjangoFormMutationOptions(DjangoFormMutationOptions):
@ -163,7 +141,9 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
registry = get_global_registry()
model_type = registry.get_type_for_model(model)
return_field_name = return_field_name
if not model_type:
raise Exception("No type registered for model: {}".format(model.__name__))
if not return_field_name:
model_name = model.__name__
return_field_name = model_name[:1].lower() + model_name[1:]
@ -182,6 +162,17 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
_meta=_meta, input_fields=input_fields, **options
)
@classmethod
def mutate_and_get_payload(cls, root, info, **input):
form = cls.get_form(root, info, **input)
if form.is_valid():
return cls.perform_mutate(form, info)
else:
errors = ErrorType.from_errors(form.errors)
return cls(errors=errors)
@classmethod
def perform_mutate(cls, form, info):
obj = form.save()

View File

@ -66,6 +66,10 @@ def test_should_choice_convert_string():
assert_conversion(forms.ChoiceField, String)
def test_should_multiple_choice_convert_list():
assert_conversion(forms.MultipleChoiceField, List)
def test_should_base_field_convert_string():
assert_conversion(forms.Field, String)

View File

@ -1,22 +1,49 @@
import pytest
from django import forms
from django.test import TestCase
from django.core.exceptions import ValidationError
from py.test import raises
from graphene_django.tests.models import Film, FilmDetails, Pet
from graphene import Field, ObjectType, Schema, String
from graphene_django import DjangoObjectType
from graphene_django.tests.models import Pet
from ...settings import graphene_settings
from ..mutation import DjangoFormMutation, DjangoModelFormMutation
@pytest.fixture()
def pet_type():
class PetType(DjangoObjectType):
class Meta:
model = Pet
fields = "__all__"
return PetType
class MyForm(forms.Form):
text = forms.CharField()
def clean_text(self):
text = self.cleaned_data["text"]
if text == "INVALID_INPUT":
raise ValidationError("Invalid input")
return text
def save(self):
pass
class PetForm(forms.ModelForm):
class Meta:
model = Pet
fields = "__all__"
def clean_age(self):
age = self.cleaned_data["age"]
if age >= 99:
raise ValidationError("Too old")
return age
def test_needs_form_class():
with raises(Exception) as exc:
@ -43,7 +70,7 @@ def test_has_input_fields():
assert "text" in MyMutation.Input._meta.fields
def test_mutation_error_camelcased():
def test_mutation_error_camelcased(pet_type, graphene_settings):
class ExtraPetForm(PetForm):
test_field = forms.CharField(required=True)
@ -56,105 +83,237 @@ def test_mutation_error_camelcased():
graphene_settings.CAMELCASE_ERRORS = False
result = PetMutation.mutate_and_get_payload(None, None)
assert {f.field for f in result.errors} == {"name", "age", "test_field"}
graphene_settings.CAMELCASE_ERRORS = True
class ModelFormMutationTests(TestCase):
def test_default_meta_fields(self):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
class MockQuery(ObjectType):
a = String()
self.assertEqual(PetMutation._meta.model, Pet)
self.assertEqual(PetMutation._meta.return_field_name, "pet")
self.assertIn("pet", PetMutation._meta.fields)
def test_default_input_meta_fields(self):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
def test_form_invalid_form():
class MyMutation(DjangoFormMutation):
class Meta:
form_class = MyForm
self.assertEqual(PetMutation._meta.model, Pet)
self.assertEqual(PetMutation._meta.return_field_name, "pet")
self.assertIn("name", PetMutation.Input._meta.fields)
self.assertIn("client_mutation_id", PetMutation.Input._meta.fields)
self.assertIn("id", PetMutation.Input._meta.fields)
class Mutation(ObjectType):
my_mutation = MyMutation.Field()
def test_exclude_fields_input_meta_fields(self):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
exclude_fields = ["id"]
schema = Schema(query=MockQuery, mutation=Mutation)
self.assertEqual(PetMutation._meta.model, Pet)
self.assertEqual(PetMutation._meta.return_field_name, "pet")
self.assertIn("name", PetMutation.Input._meta.fields)
self.assertIn("age", PetMutation.Input._meta.fields)
self.assertIn("client_mutation_id", PetMutation.Input._meta.fields)
self.assertNotIn("id", PetMutation.Input._meta.fields)
result = schema.execute(
""" mutation MyMutation {
myMutation(input: { text: "INVALID_INPUT" }) {
errors {
field
messages
}
text
}
}
"""
)
def test_return_field_name_is_camelcased(self):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
model = FilmDetails
assert result.errors is None
assert result.data["myMutation"]["errors"] == [
{"field": "text", "messages": ["Invalid input"]}
]
self.assertEqual(PetMutation._meta.model, FilmDetails)
self.assertEqual(PetMutation._meta.return_field_name, "filmDetails")
def test_custom_return_field_name(self):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
model = Film
return_field_name = "animal"
def test_form_valid_input():
class MyMutation(DjangoFormMutation):
class Meta:
form_class = MyForm
self.assertEqual(PetMutation._meta.model, Film)
self.assertEqual(PetMutation._meta.return_field_name, "animal")
self.assertIn("animal", PetMutation._meta.fields)
class Mutation(ObjectType):
my_mutation = MyMutation.Field()
def test_model_form_mutation_mutate(self):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
schema = Schema(query=MockQuery, mutation=Mutation)
pet = Pet.objects.create(name="Axel", age=10)
result = schema.execute(
""" mutation MyMutation {
myMutation(input: { text: "VALID_INPUT" }) {
errors {
field
messages
}
text
}
}
"""
)
result = PetMutation.mutate_and_get_payload(
None, None, id=pet.pk, name="Mia", age=10
)
assert result.errors is None
assert result.data["myMutation"]["errors"] == []
assert result.data["myMutation"]["text"] == "VALID_INPUT"
self.assertEqual(Pet.objects.count(), 1)
pet.refresh_from_db()
self.assertEqual(pet.name, "Mia")
self.assertEqual(result.errors, [])
def test_model_form_mutation_updates_existing_(self):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
def test_default_meta_fields(pet_type):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
result = PetMutation.mutate_and_get_payload(None, None, name="Mia", age=10)
assert PetMutation._meta.model is Pet
assert PetMutation._meta.return_field_name == "pet"
assert "pet" in PetMutation._meta.fields
self.assertEqual(Pet.objects.count(), 1)
pet = Pet.objects.get()
self.assertEqual(pet.name, "Mia")
self.assertEqual(pet.age, 10)
self.assertEqual(result.errors, [])
def test_model_form_mutation_mutate_invalid_form(self):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
def test_default_input_meta_fields(pet_type):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
result = PetMutation.mutate_and_get_payload(None, None)
assert PetMutation._meta.model is Pet
assert PetMutation._meta.return_field_name == "pet"
assert "name" in PetMutation.Input._meta.fields
assert "client_mutation_id" in PetMutation.Input._meta.fields
assert "id" in PetMutation.Input._meta.fields
# A pet was not created
self.assertEqual(Pet.objects.count(), 0)
fields_w_error = [e.field for e in result.errors]
self.assertEqual(len(result.errors), 2)
self.assertIn("name", fields_w_error)
self.assertEqual(result.errors[0].messages, ["This field is required."])
self.assertIn("age", fields_w_error)
self.assertEqual(result.errors[1].messages, ["This field is required."])
def test_exclude_fields_input_meta_fields(pet_type):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
exclude_fields = ["id"]
assert PetMutation._meta.model is Pet
assert PetMutation._meta.return_field_name == "pet"
assert "name" in PetMutation.Input._meta.fields
assert "age" in PetMutation.Input._meta.fields
assert "client_mutation_id" in PetMutation.Input._meta.fields
assert "id" not in PetMutation.Input._meta.fields
def test_custom_return_field_name(pet_type):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
model = Pet
return_field_name = "animal"
assert PetMutation._meta.model is Pet
assert PetMutation._meta.return_field_name == "animal"
assert "animal" in PetMutation._meta.fields
def test_model_form_mutation_mutate_existing(pet_type):
class PetMutation(DjangoModelFormMutation):
pet = Field(pet_type)
class Meta:
form_class = PetForm
class Mutation(ObjectType):
pet_mutation = PetMutation.Field()
schema = Schema(query=MockQuery, mutation=Mutation)
pet = Pet.objects.create(name="Axel", age=10)
result = schema.execute(
""" mutation PetMutation($pk: ID!) {
petMutation(input: { id: $pk, name: "Mia", age: 10 }) {
pet {
name
age
}
}
}
""",
variable_values={"pk": pet.pk},
)
assert result.errors is None
assert result.data["petMutation"]["pet"] == {"name": "Mia", "age": 10}
assert Pet.objects.count() == 1
pet.refresh_from_db()
assert pet.name == "Mia"
def test_model_form_mutation_creates_new(pet_type):
class PetMutation(DjangoModelFormMutation):
pet = Field(pet_type)
class Meta:
form_class = PetForm
class Mutation(ObjectType):
pet_mutation = PetMutation.Field()
schema = Schema(query=MockQuery, mutation=Mutation)
result = schema.execute(
""" mutation PetMutation {
petMutation(input: { name: "Mia", age: 10 }) {
pet {
name
age
}
errors {
field
messages
}
}
}
"""
)
assert result.errors is None
assert result.data["petMutation"]["pet"] == {"name": "Mia", "age": 10}
assert Pet.objects.count() == 1
pet = Pet.objects.get()
assert pet.name == "Mia"
assert pet.age == 10
def test_model_form_mutation_invalid_input(pet_type):
class PetMutation(DjangoModelFormMutation):
pet = Field(pet_type)
class Meta:
form_class = PetForm
class Mutation(ObjectType):
pet_mutation = PetMutation.Field()
schema = Schema(query=MockQuery, mutation=Mutation)
result = schema.execute(
""" mutation PetMutation {
petMutation(input: { name: "Mia", age: 99 }) {
pet {
name
age
}
errors {
field
messages
}
}
}
"""
)
assert result.errors is None
assert result.data["petMutation"]["pet"] is None
assert result.data["petMutation"]["errors"] == [
{"field": "age", "messages": ["Too old"]}
]
assert Pet.objects.count() == 0
def test_model_form_mutation_mutate_invalid_form(pet_type):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
result = PetMutation.mutate_and_get_payload(None, None)
# A pet was not created
Pet.objects.count() == 0
fields_w_error = [e.field for e in result.errors]
assert len(result.errors) == 2
assert result.errors[0].messages == ["This field is required."]
assert result.errors[1].messages == ["This field is required."]
assert "age" in fields_w_error
assert "name" in fields_w_error

View File

@ -1,3 +1,4 @@
import os
import importlib
import json
import functools
@ -5,6 +6,7 @@ import functools
from django.core.management.base import BaseCommand, CommandError
from django.utils import autoreload
from graphql import print_schema
from graphene_django.settings import graphene_settings
@ -44,24 +46,41 @@ class CommandArguments(BaseCommand):
class Command(CommandArguments):
help = "Dump Graphene schema JSON to file"
help = "Dump Graphene schema as a JSON or GraphQL file"
can_import_settings = True
requires_system_checks = False
def save_file(self, out, schema_dict, indent):
def save_json_file(self, out, schema_dict, indent):
with open(out, "w") as outfile:
json.dump(schema_dict, outfile, indent=indent, sort_keys=True)
def save_graphql_file(self, out, schema):
with open(out, "w") as outfile:
outfile.write(print_schema(schema.graphql_schema))
def get_schema(self, schema, out, indent):
schema_dict = {"data": schema.introspect()}
if out == "-":
self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True))
else:
self.save_file(out, schema_dict, indent)
# Determine format
_, file_extension = os.path.splitext(out)
if file_extension == ".graphql":
self.save_graphql_file(out, schema)
elif file_extension == ".json":
self.save_json_file(out, schema_dict, indent)
else:
raise CommandError(
'Unrecognised file format "{}"'.format(file_extension)
)
style = getattr(self, "style", None)
success = getattr(style, "SUCCESS", lambda x: x)
self.stdout.write(success("Successfully dumped GraphQL schema to %s" % out))
self.stdout.write(
success("Successfully dumped GraphQL schema to {}".format(out))
)
def handle(self, *args, **options):
options_schema = options.get("schema")

View File

@ -9,3 +9,8 @@ class MyFakeModel(models.Model):
class MyFakeModelWithPassword(models.Model):
cool_name = models.CharField(max_length=50)
password = models.CharField(max_length=50)
class MyFakeModelWithDate(models.Model):
cool_name = models.CharField(max_length=50)
last_edited = models.DateField()

View File

@ -1,6 +1,7 @@
from collections import OrderedDict
from django.shortcuts import get_object_or_404
from rest_framework import serializers
import graphene
from graphene.relay.mutation import ClientIDMutation
@ -19,22 +20,31 @@ class SerializerMutationOptions(MutationOptions):
serializer_class = None
def fields_for_serializer(serializer, only_fields, exclude_fields, is_input=False):
def fields_for_serializer(
serializer,
only_fields,
exclude_fields,
is_input=False,
convert_choices_to_enum=True,
):
fields = OrderedDict()
for name, field in serializer.fields.items():
is_not_in_only = only_fields and name not in only_fields
is_excluded = (
name
in exclude_fields # or
# name in already_created_fields
) or (
field.write_only and not is_input # don't show write_only fields in Query
is_excluded = any(
[
name in exclude_fields,
field.write_only
and not is_input, # don't show write_only fields in Query
field.read_only and is_input, # don't show read_only fields in Input
]
)
if is_not_in_only or is_excluded:
continue
fields[name] = convert_serializer_field(field, is_input=is_input)
fields[name] = convert_serializer_field(
field, is_input=is_input, convert_choices_to_enum=convert_choices_to_enum
)
return fields
@ -55,6 +65,7 @@ class SerializerMutation(ClientIDMutation):
model_operations=("create", "update"),
only_fields=(),
exclude_fields=(),
convert_choices_to_enum=True,
**options
):
@ -74,10 +85,18 @@ class SerializerMutation(ClientIDMutation):
lookup_field = model_class._meta.pk.name
input_fields = fields_for_serializer(
serializer, only_fields, exclude_fields, is_input=True
serializer,
only_fields,
exclude_fields,
is_input=True,
convert_choices_to_enum=convert_choices_to_enum,
)
output_fields = fields_for_serializer(
serializer, only_fields, exclude_fields, is_input=False
serializer,
only_fields,
exclude_fields,
is_input=False,
convert_choices_to_enum=convert_choices_to_enum,
)
_meta = SerializerMutationOptions(cls)
@ -102,8 +121,10 @@ class SerializerMutation(ClientIDMutation):
instance = get_object_or_404(
model_class, **{lookup_field: input[lookup_field]}
)
partial = True
elif "create" in cls._meta.model_operations:
instance = None
partial = False
else:
raise Exception(
'Invalid update operation. Input parameter "{}" required.'.format(
@ -115,6 +136,7 @@ class SerializerMutation(ClientIDMutation):
"instance": instance,
"data": input,
"context": {"request": info.context},
"partial": partial,
}
return {"data": input, "context": {"request": info.context}}
@ -138,6 +160,9 @@ class SerializerMutation(ClientIDMutation):
kwargs = {}
for f, field in serializer.fields.items():
if not field.write_only:
kwargs[f] = field.get_attribute(obj)
if isinstance(field, serializers.SerializerMethodField):
kwargs[f] = field.to_representation(obj)
else:
kwargs[f] = field.get_attribute(obj)
return cls(errors=None, **kwargs)

View File

@ -1,3 +1,5 @@
from functools import singledispatch
from django.core.exceptions import ImproperlyConfigured
from rest_framework import serializers
@ -5,11 +7,8 @@ import graphene
from ..registry import get_global_registry
from ..converter import convert_choices_to_named_enum_with_descriptions
from ..utils import import_single_dispatch
from .types import DictType
singledispatch = import_single_dispatch()
@singledispatch
def get_graphene_type_from_serializer_field(field):
@ -19,14 +18,17 @@ def get_graphene_type_from_serializer_field(field):
)
def convert_serializer_field(field, is_input=True):
def convert_serializer_field(field, is_input=True, convert_choices_to_enum=True):
"""
Converts a django rest frameworks field to a graphql field
and marks the field as required if we are creating an input type
and the field itself is required
"""
graphql_type = get_graphene_type_from_serializer_field(field)
if isinstance(field, serializers.ChoiceField) and not convert_choices_to_enum:
graphql_type = graphene.String
else:
graphql_type = get_graphene_type_from_serializer_field(field)
args = []
kwargs = {"description": field.help_text, "required": is_input and field.required}

View File

@ -10,7 +10,9 @@ from ..serializer_converter import convert_serializer_field
from ..types import DictType
def _get_type(rest_framework_field, is_input=True, **kwargs):
def _get_type(
rest_framework_field, is_input=True, convert_choices_to_enum=True, **kwargs
):
# prevents the following error:
# AssertionError: The `source` argument is not meaningful when applied to a `child=` field.
# Remove `source=` from the field declaration.
@ -21,7 +23,9 @@ def _get_type(rest_framework_field, is_input=True, **kwargs):
field = rest_framework_field(**kwargs)
return convert_serializer_field(field, is_input=is_input)
return convert_serializer_field(
field, is_input=is_input, convert_choices_to_enum=convert_choices_to_enum
)
def assert_conversion(rest_framework_field, graphene_field, **kwargs):
@ -73,6 +77,16 @@ def test_should_choice_convert_enum():
assert field._meta.enum.__members__["W"].description == "World"
def test_should_choice_convert_string_if_enum_disabled():
assert_conversion(
serializers.ChoiceField,
graphene.String,
choices=[("h", "Hello"), ("w", "World")],
source="word",
convert_choices_to_enum=False,
)
def test_should_base_field_convert_string():
assert_conversion(serializers.Field, graphene.String)

View File

@ -1,14 +1,11 @@
import graphene
import pytest
from django.db import models
from graphene import Schema
from rest_framework import serializers
import graphene
from graphene import Schema
from graphene_django import DjangoObjectType
from graphene_django.rest_framework.mutation import SerializerMutation
pytestmark = pytest.mark.django_db
class MyFakeChildModel(models.Model):
name = models.CharField(max_length=50)

View File

@ -1,14 +1,13 @@
import datetime
from py.test import mark, raises
from py.test import raises
from rest_framework import serializers
from graphene import Field, ResolveInfo
from graphene.types.inputobjecttype import InputObjectType
from ...settings import graphene_settings
from ...types import DjangoObjectType
from ..models import MyFakeModel, MyFakeModelWithPassword
from ..models import MyFakeModel, MyFakeModelWithDate, MyFakeModelWithPassword
from ..mutation import SerializerMutation
@ -18,12 +17,14 @@ def mock_info():
None,
None,
None,
path=None,
schema=None,
fragments=None,
root_value=None,
operation=None,
variable_values=None,
context=None,
is_awaitable=None,
)
@ -33,6 +34,18 @@ class MyModelSerializer(serializers.ModelSerializer):
fields = "__all__"
class MyModelSerializerWithMethod(serializers.ModelSerializer):
days_since_last_edit = serializers.SerializerMethodField()
class Meta:
model = MyFakeModelWithDate
fields = "__all__"
def get_days_since_last_edit(self, obj):
now = datetime.date(2020, 1, 8)
return (now - obj.last_edited).days
class MyModelMutation(SerializerMutation):
class Meta:
serializer_class = MyModelSerializer
@ -87,7 +100,6 @@ def test_exclude_fields():
assert "created" not in MyMutation.Input._meta.fields
@mark.django_db
def test_write_only_field():
class WriteOnlyFieldModelSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True)
@ -110,7 +122,6 @@ def test_write_only_field():
), "'password' is write_only field and shouldn't be visible"
@mark.django_db
def test_write_only_field_using_extra_kwargs():
class WriteOnlyFieldModelSerializer(serializers.ModelSerializer):
class Meta:
@ -132,6 +143,24 @@ def test_write_only_field_using_extra_kwargs():
), "'password' is write_only field and shouldn't be visible"
def test_read_only_fields():
class ReadOnlyFieldModelSerializer(serializers.ModelSerializer):
cool_name = serializers.CharField(read_only=True)
class Meta:
model = MyFakeModelWithPassword
fields = ["cool_name", "password"]
class MyMutation(SerializerMutation):
class Meta:
serializer_class = ReadOnlyFieldModelSerializer
assert "password" in MyMutation.Input._meta.fields
assert (
"cool_name" not in MyMutation.Input._meta.fields
), "'cool_name' is read_only field and shouldn't be on arguments"
def test_nested_model():
class MyFakeModelGrapheneType(DjangoObjectType):
class Meta:
@ -163,7 +192,6 @@ def test_mutate_and_get_payload_success():
assert result.errors is None
@mark.django_db
def test_model_add_mutate_and_get_payload_success():
result = MyModelMutation.mutate_and_get_payload(
None, mock_info(), **{"cool_name": "Narf"}
@ -173,7 +201,6 @@ def test_model_add_mutate_and_get_payload_success():
assert isinstance(result.created, datetime.datetime)
@mark.django_db
def test_model_update_mutate_and_get_payload_success():
instance = MyFakeModel.objects.create(cool_name="Narf")
result = MyModelMutation.mutate_and_get_payload(
@ -183,7 +210,15 @@ def test_model_update_mutate_and_get_payload_success():
assert result.cool_name == "New Narf"
@mark.django_db
def test_model_partial_update_mutate_and_get_payload_success():
instance = MyFakeModel.objects.create(cool_name="Narf")
result = MyModelMutation.mutate_and_get_payload(
None, mock_info(), **{"id": instance.id}
)
assert result.errors is None
assert result.cool_name == "Narf"
def test_model_invalid_update_mutate_and_get_payload_success():
class InvalidModelMutation(SerializerMutation):
class Meta:
@ -198,6 +233,22 @@ def test_model_invalid_update_mutate_and_get_payload_success():
assert '"id" required' in str(exc.value)
def test_perform_mutate_success():
class MyMethodMutation(SerializerMutation):
class Meta:
serializer_class = MyModelSerializerWithMethod
result = MyMethodMutation.mutate_and_get_payload(
None,
mock_info(),
**{"cool_name": "Narf", "last_edited": datetime.date(2020, 1, 4)}
)
assert result.errors is None
assert result.cool_name == "Narf"
assert result.days_since_last_edit == 4
def test_mutate_and_get_payload_error():
class MyMutation(SerializerMutation):
class Meta:
@ -214,11 +265,10 @@ def test_model_mutate_and_get_payload_error():
assert len(result.errors) > 0
def test_mutation_error_camelcased():
def test_mutation_error_camelcased(graphene_settings):
graphene_settings.CAMELCASE_ERRORS = True
result = MyModelMutation.mutate_and_get_payload(None, mock_info(), **{})
assert result.errors[0].field == "coolName"
graphene_settings.CAMELCASE_ERRORS = False
def test_invalid_serializer_operations():

View File

@ -15,7 +15,6 @@ from __future__ import unicode_literals
from django.conf import settings
from django.test.signals import setting_changed
from django.utils import six
try:
import importlib # Available in Python 3.1+
@ -36,6 +35,9 @@ DEFAULTS = {
# Max items returned in ConnectionFields / FilterConnectionFields
"RELAY_CONNECTION_MAX_LIMIT": 100,
"CAMELCASE_ERRORS": True,
# Set to True to enable v3 naming convention for choice field Enum's
"DJANGO_CHOICE_FIELD_ENUM_V3_NAMING": False,
"DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME": None,
}
if settings.DEBUG:
@ -52,7 +54,7 @@ def perform_import(val, setting_name):
"""
if val is None:
return None
elif isinstance(val, six.string_types):
elif isinstance(val, str):
return import_from_string(val, setting_name)
elif isinstance(val, (list, tuple)):
return [import_from_string(item, setting_name) for item in val]

View File

@ -3,8 +3,11 @@
// Parse the cookie value for a CSRF token
var csrftoken;
var cookies = ('; ' + document.cookie).split('; csrftoken=');
if (cookies.length == 2)
if (cookies.length == 2) {
csrftoken = cookies.pop().split(';').shift();
} else {
csrftoken = document.querySelector("[name=csrfmiddlewaretoken]").value;
}
// Collect the URL parameters
var parameters = {};
@ -94,6 +97,6 @@
// Render <GraphiQL /> into the body.
ReactDOM.render(
React.createElement(GraphiQL, options),
document.body
document.getElementById("editor")
);
})();

View File

@ -10,7 +10,7 @@ add "&raw" to the end of the URL within a browser.
<html>
<head>
<style>
html, body {
html, body, #editor {
height: 100%;
margin: 0;
overflow: hidden;
@ -31,6 +31,8 @@ add "&raw" to the end of the URL within a browser.
crossorigin="anonymous"></script>
</head>
<body>
<div id="editor"></div>
{% csrf_token %}
<script src="{% static 'graphene_django/graphiql.js' %}"></script>
</body>
</html>

View File

@ -1,7 +1,7 @@
from __future__ import absolute_import
from django.db import models
from django.utils.translation import ugettext_lazy as _
from django.utils.translation import gettext_lazy as _
CHOICES = ((1, "this"), (2, _("that")))
@ -46,7 +46,7 @@ class Reporter(models.Model):
"Reporter Type",
null=True,
blank=True,
choices=[(1, u"Regular"), (2, u"CNN Reporter")],
choices=[(1, "Regular"), (2, "CNN Reporter")],
)
def __str__(self): # __unicode__ on Python 2
@ -64,6 +64,9 @@ class Reporter(models.Model):
if self.reporter_type == 2: # quick and dirty way without enums
self.__class__ = CNNReporter
def some_method(self):
return 123
class CNNReporterManager(models.Manager):
def get_queryset(self):
@ -102,7 +105,7 @@ class Article(models.Model):
"Importance",
null=True,
blank=True,
choices=[(1, u"Very important"), (2, u"Not as important")],
choices=[(1, "Very important"), (2, "Not as important")],
)
def __str__(self): # __unicode__ on Python 2

View File

@ -1,17 +1,21 @@
from textwrap import dedent
from django.core import management
from mock import patch, mock_open
from six import StringIO
from io import StringIO
from mock import mock_open, patch
from graphene import ObjectType, Schema, String
@patch("graphene_django.management.commands.graphql_schema.Command.save_file")
def test_generate_file_on_call_graphql_schema(savefile_mock, settings):
@patch("graphene_django.management.commands.graphql_schema.Command.save_json_file")
def test_generate_json_file_on_call_graphql_schema(savefile_mock):
out = StringIO()
management.call_command("graphql_schema", schema="", stdout=out)
assert "Successfully dumped GraphQL schema to schema.json" in out.getvalue()
@patch("json.dump")
def test_files_are_canonical(dump_mock):
def test_json_files_are_canonical(dump_mock):
open_mock = mock_open()
with patch("graphene_django.management.commands.graphql_schema.open", open_mock):
management.call_command("graphql_schema", schema="")
@ -25,3 +29,30 @@ def test_files_are_canonical(dump_mock):
assert (
dump_mock.call_args[1]["indent"] > 0
), "output should be pretty-printed by default"
def test_generate_graphql_file_on_call_graphql_schema():
class Query(ObjectType):
hi = String()
mock_schema = Schema(query=Query)
open_mock = mock_open()
with patch("graphene_django.management.commands.graphql_schema.open", open_mock):
management.call_command(
"graphql_schema", schema=mock_schema, out="schema.graphql"
)
open_mock.assert_called_once()
handle = open_mock()
assert handle.write.called_once()
schema_output = handle.write.call_args[0][0]
assert schema_output == dedent(
"""\
type Query {
hi: String
}
"""
)

View File

@ -1,31 +1,43 @@
from collections import namedtuple
import pytest
from django.db import models
from django.utils.translation import ugettext_lazy as _
from graphene import NonNull
from django.utils.translation import gettext_lazy as _
from py.test import raises
import graphene
from graphene import NonNull
from graphene.relay import ConnectionField, Node
from graphene.types.datetime import DateTime, Date, Time
from graphene.types.datetime import Date, DateTime, Time
from graphene.types.json import JSONString
from ..compat import JSONField, ArrayField, HStoreField, RangeField, MissingType
from ..converter import convert_django_field, convert_django_field_with_choices
from ..compat import ArrayField, HStoreField, JSONField, MissingType, RangeField
from ..converter import (
convert_django_field,
convert_django_field_with_choices,
generate_enum_name,
)
from ..registry import Registry
from ..types import DjangoObjectType
from .models import Article, Film, FilmDetails, Reporter
# from graphene.core.types.custom_scalars import DateTime, Time, JSONString
def assert_conversion(django_field, graphene_field, *args, **kwargs):
field = django_field(help_text="Custom Help Text", null=True, *args, **kwargs)
_kwargs = kwargs.copy()
if "null" not in kwargs:
_kwargs["null"] = True
field = django_field(help_text="Custom Help Text", *args, **_kwargs)
graphene_type = convert_django_field(field)
assert isinstance(graphene_type, graphene_field)
field = graphene_type.Field()
assert field.description == "Custom Help Text"
nonnull_field = django_field(null=False, *args, **kwargs)
_kwargs = kwargs.copy()
if "null" not in kwargs:
_kwargs["null"] = False
nonnull_field = django_field(*args, **_kwargs)
if not nonnull_field.null:
nonnull_graphene_type = convert_django_field(nonnull_field)
nonnull_field = nonnull_graphene_type.Field()
@ -121,7 +133,12 @@ def test_should_integer_convert_int():
def test_should_boolean_convert_boolean():
field = assert_conversion(models.BooleanField, graphene.NonNull)
assert_conversion(models.BooleanField, graphene.Boolean, null=True)
def test_should_boolean_convert_non_null_boolean():
field = assert_conversion(models.BooleanField, graphene.Boolean, null=False)
assert isinstance(field.type, graphene.NonNull)
assert field.type.of_type == graphene.Boolean
@ -325,3 +342,23 @@ def test_should_postgres_range_convert_list():
assert isinstance(field.type, graphene.NonNull)
assert isinstance(field.type.of_type, graphene.List)
assert field.type.of_type.of_type == graphene.Int
def test_generate_enum_name(graphene_settings):
MockDjangoModelMeta = namedtuple("DjangoMeta", ["app_label", "object_name"])
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = True
# Simple case
field = graphene.Field(graphene.String, name="type")
model_meta = MockDjangoModelMeta(app_label="users", object_name="User")
assert generate_enum_name(model_meta, field) == "UsersUserTypeChoices"
# More complicated multiple work case
field = graphene.Field(graphene.String, name="fizz_buzz")
model_meta = MockDjangoModelMeta(
app_label="some_long_app_name", object_name="SomeObject"
)
assert (
generate_enum_name(model_meta, field)
== "SomeLongAppNameSomeObjectFizzBuzzChoices"
)

View File

@ -1,4 +1,5 @@
import datetime
from django.db.models import Count
import pytest
@ -10,7 +11,6 @@ from .models import Article as ArticleModel
from .models import Reporter as ReporterModel
@pytest.mark.django_db
class TestDjangoListField:
def test_only_django_object_types(self):
class TestType(ObjectType):
@ -19,6 +19,12 @@ class TestDjangoListField:
with pytest.raises(AssertionError):
list_field = DjangoListField(TestType)
def test_only_import_paths(self):
list_field = DjangoListField("graphene_django.tests.schema.Human")
from .schema import Human
assert list_field._type.of_type.of_type is Human
def test_non_null_type(self):
class Reporter(DjangoObjectType):
class Meta:
@ -136,13 +142,26 @@ class TestDjangoListField:
pub_date_time=datetime.datetime.now(),
editor=r1,
)
ArticleModel.objects.create(
headline="Not so good news",
reporter=r1,
pub_date=datetime.date.today(),
pub_date_time=datetime.datetime.now(),
editor=r1,
)
result = schema.execute(query)
assert not result.errors
assert result.data == {
"reporters": [
{"firstName": "Tara", "articles": [{"headline": "Amazing news"}]},
{
"firstName": "Tara",
"articles": [
{"headline": "Amazing news"},
{"headline": "Not so good news"},
],
},
{"firstName": "Debra", "articles": []},
]
}
@ -158,8 +177,8 @@ class TestDjangoListField:
model = ReporterModel
fields = ("first_name", "articles")
def resolve_reporters(reporter, info):
return reporter.articles.all()
def resolve_articles(reporter, info):
return reporter.articles.filter(headline__contains="Amazing")
class Query(ObjectType):
reporters = DjangoListField(Reporter)
@ -187,6 +206,13 @@ class TestDjangoListField:
pub_date_time=datetime.datetime.now(),
editor=r1,
)
ArticleModel.objects.create(
headline="Not so good news",
reporter=r1,
pub_date=datetime.date.today(),
pub_date_time=datetime.datetime.now(),
editor=r1,
)
result = schema.execute(query)
@ -197,3 +223,155 @@ class TestDjangoListField:
{"firstName": "Debra", "articles": []},
]
}
def test_get_queryset_filter(self):
class Reporter(DjangoObjectType):
class Meta:
model = ReporterModel
fields = ("first_name", "articles")
@classmethod
def get_queryset(cls, queryset, info):
# Only get reporters with at least 1 article
return queryset.annotate(article_count=Count("articles")).filter(
article_count__gt=0
)
class Query(ObjectType):
reporters = DjangoListField(Reporter)
def resolve_reporters(_, info):
return ReporterModel.objects.all()
schema = Schema(query=Query)
query = """
query {
reporters {
firstName
}
}
"""
r1 = ReporterModel.objects.create(first_name="Tara", last_name="West")
ReporterModel.objects.create(first_name="Debra", last_name="Payne")
ArticleModel.objects.create(
headline="Amazing news",
reporter=r1,
pub_date=datetime.date.today(),
pub_date_time=datetime.datetime.now(),
editor=r1,
)
result = schema.execute(query)
assert not result.errors
assert result.data == {"reporters": [{"firstName": "Tara"},]}
def test_resolve_list(self):
"""Resolving a plain list should work (and not call get_queryset)"""
class Reporter(DjangoObjectType):
class Meta:
model = ReporterModel
fields = ("first_name", "articles")
@classmethod
def get_queryset(cls, queryset, info):
# Only get reporters with at least 1 article
return queryset.annotate(article_count=Count("articles")).filter(
article_count__gt=0
)
class Query(ObjectType):
reporters = DjangoListField(Reporter)
def resolve_reporters(_, info):
return [ReporterModel.objects.get(first_name="Debra")]
schema = Schema(query=Query)
query = """
query {
reporters {
firstName
}
}
"""
r1 = ReporterModel.objects.create(first_name="Tara", last_name="West")
ReporterModel.objects.create(first_name="Debra", last_name="Payne")
ArticleModel.objects.create(
headline="Amazing news",
reporter=r1,
pub_date=datetime.date.today(),
pub_date_time=datetime.datetime.now(),
editor=r1,
)
result = schema.execute(query)
assert not result.errors
assert result.data == {"reporters": [{"firstName": "Debra"},]}
def test_get_queryset_foreign_key(self):
class Article(DjangoObjectType):
class Meta:
model = ArticleModel
fields = ("headline",)
@classmethod
def get_queryset(cls, queryset, info):
# Rose tinted glasses
return queryset.exclude(headline__contains="Not so good")
class Reporter(DjangoObjectType):
class Meta:
model = ReporterModel
fields = ("first_name", "articles")
class Query(ObjectType):
reporters = DjangoListField(Reporter)
schema = Schema(query=Query)
query = """
query {
reporters {
firstName
articles {
headline
}
}
}
"""
r1 = ReporterModel.objects.create(first_name="Tara", last_name="West")
ReporterModel.objects.create(first_name="Debra", last_name="Payne")
ArticleModel.objects.create(
headline="Amazing news",
reporter=r1,
pub_date=datetime.date.today(),
pub_date_time=datetime.datetime.now(),
editor=r1,
)
ArticleModel.objects.create(
headline="Not so good news",
reporter=r1,
pub_date=datetime.date.today(),
pub_date_time=datetime.datetime.now(),
editor=r1,
)
result = schema.execute(query)
assert not result.errors
assert result.data == {
"reporters": [
{"firstName": "Tara", "articles": [{"headline": "Amazing news"},],},
{"firstName": "Debra", "articles": []},
]
}

View File

@ -1,25 +1,20 @@
import base64
import datetime
import pytest
from django.db import models
from django.db.models import Q
from django.utils.functional import SimpleLazyObject
from graphql_relay import to_global_id
from py.test import raises
from django.db.models import Q
from graphql_relay import to_global_id
import graphene
from graphene.relay import Node
from ..utils import DJANGO_FILTER_INSTALLED
from ..compat import MissingType, JSONField
from ..compat import JSONField, MissingType
from ..fields import DjangoConnectionField
from ..types import DjangoObjectType
from ..settings import graphene_settings
from .models import Article, CNNReporter, Reporter, Film, FilmDetails
pytestmark = pytest.mark.django_db
from ..utils import DJANGO_FILTER_INSTALLED
from .models import Article, CNNReporter, Film, FilmDetails, Reporter
def test_should_query_only_fields():
@ -147,9 +142,6 @@ def test_should_query_postgres_fields():
def test_should_node():
# reset_global_registry()
# Node._meta.registry = get_global_registry()
class ReporterNode(DjangoObjectType):
class Meta:
model = Reporter
@ -588,7 +580,7 @@ def test_should_query_node_multiple_filtering():
assert result.data == expected
def test_should_enforce_first_or_last():
def test_should_enforce_first_or_last(graphene_settings):
graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST = True
class ReporterType(DjangoObjectType):
@ -620,14 +612,14 @@ def test_should_enforce_first_or_last():
result = schema.execute(query)
assert len(result.errors) == 1
assert str(result.errors[0]) == (
assert str(result.errors[0]).startswith(
"You must provide a `first` or `last` value to properly "
"paginate the `allReporters` connection."
"paginate the `allReporters` connection.\n"
)
assert result.data == expected
def test_should_error_if_first_is_greater_than_max():
def test_should_error_if_first_is_greater_than_max(graphene_settings):
graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 100
class ReporterType(DjangoObjectType):
@ -638,6 +630,8 @@ def test_should_error_if_first_is_greater_than_max():
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
assert Query.all_reporters.max_limit == 100
r = Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
@ -659,16 +653,14 @@ def test_should_error_if_first_is_greater_than_max():
result = schema.execute(query)
assert len(result.errors) == 1
assert str(result.errors[0]) == (
assert str(result.errors[0]).startswith(
"Requesting 101 records on the `allReporters` connection "
"exceeds the `first` limit of 100 records."
"exceeds the `first` limit of 100 records.\n"
)
assert result.data == expected
graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST = False
def test_should_error_if_last_is_greater_than_max():
def test_should_error_if_last_is_greater_than_max(graphene_settings):
graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 100
class ReporterType(DjangoObjectType):
@ -679,6 +671,8 @@ def test_should_error_if_last_is_greater_than_max():
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
assert Query.all_reporters.max_limit == 100
r = Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
@ -700,14 +694,12 @@ def test_should_error_if_last_is_greater_than_max():
result = schema.execute(query)
assert len(result.errors) == 1
assert str(result.errors[0]) == (
assert str(result.errors[0]).startswith(
"Requesting 101 records on the `allReporters` connection "
"exceeds the `last` limit of 100 records."
"exceeds the `last` limit of 100 records.\n"
)
assert result.data == expected
graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST = False
def test_should_query_promise_connectionfields():
from promise import Promise
@ -721,7 +713,7 @@ def test_should_query_promise_connectionfields():
all_reporters = DjangoConnectionField(ReporterType)
def resolve_all_reporters(self, info, **args):
return Promise.resolve([Reporter(id=1)])
return Promise.resolve([Reporter(id=1)]).get()
schema = graphene.Schema(query=Query)
query = """
@ -804,7 +796,7 @@ def test_should_query_connectionfields_with_manager():
schema = graphene.Schema(query=Query)
query = """
query ReporterLastQuery {
allReporters(first: 2) {
allReporters(first: 1) {
edges {
node {
id
@ -850,7 +842,7 @@ def test_should_query_dataloader_fields():
articles = DjangoConnectionField(ArticleType)
def resolve_articles(self, info, **args):
return article_loader.load(self.id)
return article_loader.load(self.id).get()
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
@ -1083,7 +1075,7 @@ def test_should_preserve_prefetch_related(django_assert_num_queries):
class Query(graphene.ObjectType):
films = DjangoConnectionField(FilmType)
def resolve_films(root, info):
def resolve_films(root, info, **kwargs):
qs = Film.objects.prefetch_related("reporters")
return qs
@ -1113,6 +1105,60 @@ def test_should_preserve_prefetch_related(django_assert_num_queries):
}
"""
schema = graphene.Schema(query=Query)
with django_assert_num_queries(3) as captured:
result = schema.execute(query)
assert not result.errors
def test_should_preserve_annotations():
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (graphene.relay.Node,)
class FilmType(DjangoObjectType):
reporters = DjangoConnectionField(ReporterType)
reporters_count = graphene.Int()
class Meta:
model = Film
interfaces = (graphene.relay.Node,)
class Query(graphene.ObjectType):
films = DjangoConnectionField(FilmType)
def resolve_films(root, info, **kwargs):
qs = Film.objects.prefetch_related("reporters")
return qs.annotate(reporters_count=models.Count("reporters"))
r1 = Reporter.objects.create(first_name="Dave", last_name="Smith")
r2 = Reporter.objects.create(first_name="Jane", last_name="Doe")
f1 = Film.objects.create()
f1.reporters.set([r1, r2])
f2 = Film.objects.create()
f2.reporters.set([r2])
query = """
query {
films {
edges {
node {
reportersCount
}
}
}
}
"""
schema = graphene.Schema(query=Query)
result = schema.execute(query)
assert not result.errors, str(result)
expected = {
"films": {
"edges": [{"node": {"reportersCount": 2}}, {"node": {"reportersCount": 1}}]
}
}
assert result.data == expected, str(result.data)
assert not result.errors

View File

@ -13,8 +13,6 @@ from ..types import DjangoObjectType, DjangoObjectTypeOptions
from .models import Article as ArticleModel
from .models import Reporter as ReporterModel
registry.reset_global_registry()
class Reporter(DjangoObjectType):
"""Reporter description"""
@ -113,90 +111,171 @@ def test_django_objecttype_with_custom_meta():
def test_schema_representation():
expected = """
schema {
query: RootQuery
}
expected = dedent(
"""\
schema {
query: RootQuery
}
type Article implements Node {
id: ID!
headline: String!
pubDate: Date!
pubDateTime: DateTime!
reporter: Reporter!
editor: Reporter!
lang: ArticleLang!
importance: ArticleImportance
}
\"""Article description\"""
type Article implements Node {
\"""The ID of the object\"""
id: ID!
type ArticleConnection {
pageInfo: PageInfo!
edges: [ArticleEdge]!
test: String
}
\"""\"""
headline: String!
type ArticleEdge {
node: Article
cursor: String!
}
\"""\"""
pubDate: Date!
enum ArticleImportance {
A_1
A_2
}
\"""\"""
pubDateTime: DateTime!
enum ArticleLang {
ES
EN
}
\"""\"""
reporter: Reporter!
scalar Date
\"""\"""
editor: Reporter!
scalar DateTime
\"""Language\"""
lang: ArticleLang!
interface Node {
id: ID!
}
\"""\"""
importance: ArticleImportance
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
\"""An object with an ID\"""
interface Node {
\"""The ID of the object\"""
id: ID!
}
type Reporter {
id: ID!
firstName: String!
lastName: String!
email: String!
pets: [Reporter!]!
aChoice: ReporterAChoice
reporterType: ReporterReporterType
articles(before: String, after: String, first: Int, last: Int): ArticleConnection!
}
\"""
The `Date` scalar type represents a Date
value as specified by
[iso8601](https://en.wikipedia.org/wiki/ISO_8601).
\"""
scalar Date
enum ReporterAChoice {
A_1
A_2
}
\"""
The `DateTime` scalar type represents a DateTime
value as specified by
[iso8601](https://en.wikipedia.org/wiki/ISO_8601).
\"""
scalar DateTime
enum ReporterReporterType {
A_1
A_2
}
\"""An enumeration.\"""
enum ArticleLang {
\"""Spanish\"""
ES
type RootQuery {
node(id: ID!): Node
}
""".lstrip()
\"""English\"""
EN
}
\"""An enumeration.\"""
enum ArticleImportance {
\"""Very important\"""
A_1
\"""Not as important\"""
A_2
}
\"""Reporter description\"""
type Reporter {
\"""\"""
id: ID!
\"""\"""
firstName: String!
\"""\"""
lastName: String!
\"""\"""
email: String!
\"""\"""
pets: [Reporter!]!
\"""\"""
aChoice: ReporterAChoice
\"""\"""
reporterType: ReporterReporterType
\"""\"""
articles(before: String = null, after: String = null, first: Int = null, last: Int = null): ArticleConnection!
}
\"""An enumeration.\"""
enum ReporterAChoice {
\"""this\"""
A_1
\"""that\"""
A_2
}
\"""An enumeration.\"""
enum ReporterReporterType {
\"""Regular\"""
A_1
\"""CNN Reporter\"""
A_2
}
type ArticleConnection {
\"""Pagination data for this connection.\"""
pageInfo: PageInfo!
\"""Contains the nodes in this connection.\"""
edges: [ArticleEdge]!
test: String
}
\"""
The Relay compliant `PageInfo` type, containing data necessary to paginate this connection.
\"""
type PageInfo {
\"""When paginating forwards, are there more items?\"""
hasNextPage: Boolean!
\"""When paginating backwards, are there more items?\"""
hasPreviousPage: Boolean!
\"""When paginating backwards, the cursor to continue.\"""
startCursor: String
\"""When paginating forwards, the cursor to continue.\"""
endCursor: String
}
\"""A Relay edge containing a `Article` and its cursor.\"""
type ArticleEdge {
\"""The item at the end of the edge\"""
node: Article
\"""A cursor for use in pagination\"""
cursor: String!
}
type RootQuery {
node(
\"""The ID of the object\"""
id: ID!
): Node
}
"""
)
assert str(schema) == expected
def with_local_registry(func):
def inner(*args, **kwargs):
old = registry.get_global_registry()
registry.reset_global_registry()
try:
retval = func(*args, **kwargs)
except Exception as e:
@ -233,6 +312,17 @@ def test_django_objecttype_fields():
assert fields == ["id", "email", "films"]
@with_local_registry
def test_django_objecttype_fields_empty():
class Reporter(DjangoObjectType):
class Meta:
model = ReporterModel
fields = ()
fields = list(Reporter._meta.fields.keys())
assert fields == []
@with_local_registry
def test_django_objecttype_only_fields_and_fields():
with pytest.raises(Exception):
@ -315,7 +405,79 @@ def test_django_objecttype_fields_exclude_type_checking():
class Reporter2(DjangoObjectType):
class Meta:
model = ReporterModel
fields = "foo"
exclude = "foo"
@with_local_registry
def test_django_objecttype_fields_exist_on_model():
with pytest.warns(UserWarning, match=r"Field name .* doesn't exist"):
class Reporter(DjangoObjectType):
class Meta:
model = ReporterModel
fields = ["first_name", "foo", "email"]
with pytest.warns(
UserWarning,
match=r"Field name .* matches an attribute on Django model .* but it's not a model field",
) as record:
class Reporter2(DjangoObjectType):
class Meta:
model = ReporterModel
fields = ["first_name", "some_method", "email"]
# Don't warn if selecting a custom field
with pytest.warns(None) as record:
class Reporter3(DjangoObjectType):
custom_field = String()
class Meta:
model = ReporterModel
fields = ["first_name", "custom_field", "email"]
assert len(record) == 0
@with_local_registry
def test_django_objecttype_exclude_fields_exist_on_model():
with pytest.warns(
UserWarning,
match=r"Django model .* does not have a field or attribute named .*",
):
class Reporter(DjangoObjectType):
class Meta:
model = ReporterModel
exclude = ["foo"]
# Don't warn if selecting a custom field
with pytest.warns(
UserWarning,
match=r"Excluding the custom field .* on DjangoObjectType .* has no effect.",
):
class Reporter3(DjangoObjectType):
custom_field = String()
class Meta:
model = ReporterModel
exclude = ["custom_field"]
# Don't warn on exclude fields
with pytest.warns(None) as record:
class Reporter4(DjangoObjectType):
class Meta:
model = ReporterModel
exclude = ["email", "first_name"]
assert len(record) == 0
def custom_enum_name(field):
return "CustomEnum{}".format(field.name.title())
class TestDjangoObjectType:
@ -346,20 +508,21 @@ class TestDjangoObjectType:
assert str(schema) == dedent(
"""\
schema {
query: Query
}
type Query {
pet: Pet
}
type Pet {
id: ID!
kind: String!
cuteness: Int!
}
type Pet {
\"""\"""
id: ID!
type Query {
pet: Pet
}
"""
\"""\"""
kind: String!
\"""\"""
cuteness: Int!
}
"""
)
def test_django_objecttype_convert_choices_enum_list(self, PetModel):
@ -375,25 +538,30 @@ class TestDjangoObjectType:
assert str(schema) == dedent(
"""\
schema {
query: Query
}
type Query {
pet: Pet
}
type Pet {
id: ID!
kind: PetModelKind!
cuteness: Int!
}
type Pet {
\"""\"""
id: ID!
enum PetModelKind {
CAT
DOG
}
\"""\"""
kind: PetModelKind!
type Query {
pet: Pet
}
"""
\"""\"""
cuteness: Int!
}
\"""An enumeration.\"""
enum PetModelKind {
\"""Cat\"""
CAT
\"""Dog\"""
DOG
}
"""
)
def test_django_objecttype_convert_choices_enum_empty_list(self, PetModel):
@ -409,18 +577,101 @@ class TestDjangoObjectType:
assert str(schema) == dedent(
"""\
schema {
query: Query
}
type Query {
pet: Pet
}
type Pet {
id: ID!
kind: String!
cuteness: Int!
}
type Pet {
\"""\"""
id: ID!
type Query {
pet: Pet
}
"""
\"""\"""
kind: String!
\"""\"""
cuteness: Int!
}
"""
)
def test_django_objecttype_convert_choices_enum_naming_collisions(
self, PetModel, graphene_settings
):
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = True
class PetModelKind(DjangoObjectType):
class Meta:
model = PetModel
fields = ["id", "kind"]
class Query(ObjectType):
pet = Field(PetModelKind)
schema = Schema(query=Query)
assert str(schema) == dedent(
"""\
type Query {
pet: PetModelKind
}
type PetModelKind {
\"""\"""
id: ID!
\"""\"""
kind: TestsPetModelKindChoices!
}
\"""An enumeration.\"""
enum TestsPetModelKindChoices {
\"""Cat\"""
CAT
\"""Dog\"""
DOG
}
"""
)
def test_django_objecttype_choices_custom_enum_name(
self, PetModel, graphene_settings
):
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME = (
"graphene_django.tests.test_types.custom_enum_name"
)
class PetModelKind(DjangoObjectType):
class Meta:
model = PetModel
fields = ["id", "kind"]
class Query(ObjectType):
pet = Field(PetModelKind)
schema = Schema(query=Query)
assert str(schema) == dedent(
"""\
type Query {
pet: PetModelKind
}
type PetModelKind {
\"""\"""
id: ID!
\"""\"""
kind: CustomEnumKind!
}
\"""An enumeration.\"""
enum CustomEnumKind {
\"""Cat\"""
CAT
\"""Dog\"""
DOG
}
"""
)

View File

@ -1,6 +1,10 @@
from django.utils.translation import gettext_lazy
import json
from ..utils import camelize, get_model_fields
import pytest
from django.utils.translation import gettext_lazy
from mock import patch
from ..utils import camelize, get_model_fields, GraphQLTestCase
from .models import Film, Reporter
@ -30,3 +34,27 @@ def test_camelize():
"valueA": "value_b"
}
assert camelize({0: {"field_a": ["errors"]}}) == {0: {"fieldA": ["errors"]}}
@pytest.mark.django_db
@patch("graphene_django.utils.testing.Client.post")
def test_graphql_test_case_op_name(post_mock):
"""
Test that `GraphQLTestCase.query()`'s `operation_name` argument produces an `operationName` field.
"""
class TestClass(GraphQLTestCase):
GRAPHQL_SCHEMA = True
def runTest(self):
pass
tc = TestClass()
tc.setUpClass()
tc.query("query { }", operation_name="QueryName")
body = json.loads(post_mock.call_args.args[1])
# `operationName` field from https://graphql.org/learn/serving-over-http/#post-request
assert (
"operationName",
"QueryName",
) in body.items(), "Field 'operationName' is not present in the final request."

View File

@ -99,12 +99,14 @@ def test_reports_validation_errors(client):
assert response_json(response) == {
"errors": [
{
"message": 'Cannot query field "unknownOne" on type "QueryRoot".',
"message": "Cannot query field 'unknownOne' on type 'QueryRoot'.",
"locations": [{"line": 1, "column": 9}],
"path": None,
},
{
"message": 'Cannot query field "unknownTwo" on type "QueryRoot".',
"message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.",
"locations": [{"line": 1, "column": 21}],
"path": None,
},
]
}
@ -124,7 +126,9 @@ def test_errors_when_missing_operation_name(client):
assert response_json(response) == {
"errors": [
{
"message": "Must provide operation name if query contains multiple operations."
"message": "Must provide operation name if query contains multiple operations.",
"locations": None,
"path": None,
}
]
}
@ -464,8 +468,8 @@ def test_handles_syntax_errors_caught_by_graphql(client):
"errors": [
{
"locations": [{"column": 1, "line": 1}],
"message": "Syntax Error GraphQL (1:1) "
'Unexpected Name "syntaxerror"\n\n1: syntaxerror\n ^\n',
"message": "Syntax Error: Unexpected Name 'syntaxerror'.",
"path": None,
}
]
}

View File

@ -1,7 +1,7 @@
import warnings
from collections import OrderedDict
from typing import Type
import six
from django.db.models import Model
from django.utils.functional import SimpleLazyObject
@ -21,9 +21,6 @@ from .utils import (
is_valid_django_model,
)
if six.PY3:
from typing import Type
ALL_FIELDS = "__all__"
@ -35,9 +32,15 @@ def construct_fields(
fields = OrderedDict()
for name, field in _model_fields:
is_not_in_only = only_fields and name not in only_fields
is_not_in_only = (
only_fields is not None
and only_fields != ALL_FIELDS
and name not in only_fields
)
# is_already_created = name in options.fields
is_excluded = name in exclude_fields # or is_already_created
is_excluded = (
exclude_fields is not None and name in exclude_fields
) # or is_already_created
# https://docs.djangoproject.com/en/1.10/ref/models/fields/#django.db.models.ForeignKey.related_query_name
is_no_backref = str(name).endswith("+")
if is_not_in_only or is_excluded or is_no_backref:
@ -62,6 +65,71 @@ def construct_fields(
return fields
def validate_fields(type_, model, fields, only_fields, exclude_fields):
# Validate the given fields against the model's fields and custom fields
all_field_names = set(fields.keys())
only_fields = only_fields if only_fields is not ALL_FIELDS else ()
for name in only_fields or ():
if name in all_field_names:
continue
if hasattr(model, name):
warnings.warn(
(
'Field name "{field_name}" matches an attribute on Django model "{app_label}.{object_name}" '
"but it's not a model field so Graphene cannot determine what type it should be. "
'Either define the type of the field on DjangoObjectType "{type_}" or remove it from the "fields" list.'
).format(
field_name=name,
app_label=model._meta.app_label,
object_name=model._meta.object_name,
type_=type_,
)
)
else:
warnings.warn(
(
'Field name "{field_name}" doesn\'t exist on Django model "{app_label}.{object_name}". '
'Consider removing the field from the "fields" list of DjangoObjectType "{type_}" because it has no effect.'
).format(
field_name=name,
app_label=model._meta.app_label,
object_name=model._meta.object_name,
type_=type_,
)
)
# Validate exclude fields
for name in exclude_fields or ():
if name in all_field_names:
# Field is a custom field
warnings.warn(
(
'Excluding the custom field "{field_name}" on DjangoObjectType "{type_}" has no effect. '
'Either remove the custom field or remove the field from the "exclude" list.'
).format(
field_name=name,
app_label=model._meta.app_label,
object_name=model._meta.object_name,
type_=type_,
)
)
else:
if not hasattr(model, name):
warnings.warn(
(
'Django model "{app_label}.{object_name}" does not have a field or attribute named "{field_name}". '
'Consider removing the field from the "exclude" list of DjangoObjectType "{type_}" because it has no effect'
).format(
field_name=name,
app_label=model._meta.app_label,
object_name=model._meta.object_name,
type_=type_,
)
)
class DjangoObjectTypeOptions(ObjectTypeOptions):
model = None # type: Model
registry = None # type: Registry
@ -78,10 +146,10 @@ class DjangoObjectType(ObjectType):
model=None,
registry=None,
skip_registry=False,
only_fields=(), # deprecated in favour of `fields`
fields=(),
exclude_fields=(), # deprecated in favour of `exclude`
exclude=(),
only_fields=None, # deprecated in favour of `fields`
fields=None,
exclude_fields=None, # deprecated in favour of `exclude`
exclude=None,
filter_fields=None,
filterset_class=None,
connection=None,
@ -136,9 +204,6 @@ class DjangoObjectType(ObjectType):
"Got %s." % type(fields).__name__
)
if fields == ALL_FIELDS:
fields = None
# Alias exclude_fields -> exclude
if exclude_fields and exclude:
raise Exception("Can't set both exclude_fields and exclude")
@ -193,6 +258,9 @@ class DjangoObjectType(ObjectType):
_meta=_meta, interfaces=interfaces, **options
)
# Validate fields
validate_fields(cls, model, _meta.fields, fields, exclude)
if not skip_registry:
registry.register(cls)

View File

@ -4,7 +4,6 @@ from .utils import (
camelize,
get_model_fields,
get_reverse_fields,
import_single_dispatch,
is_valid_django_model,
maybe_queryset,
)
@ -16,6 +15,5 @@ __all__ = [
"get_model_fields",
"camelize",
"is_valid_django_model",
"import_single_dispatch",
"GraphQLTestCase",
]

View File

@ -24,26 +24,30 @@ class GraphQLTestCase(TestCase):
cls._client = Client()
def query(self, query, op_name=None, input_data=None, variables=None):
def query(
self, query, operation_name=None, input_data=None, variables=None, headers=None
):
"""
Args:
query (string) - GraphQL query to run
op_name (string) - If the query is a mutation or named query, you must
operation_name (string) - If the query is a mutation or named query, you must
supply the op_name. For annon queries ("{ ... }"),
should be None (default).
input_data (dict) - If provided, the $input variable in GraphQL will be set
to this value. If both ``input_data`` and ``variables``,
to this value. If both ``input_data`` and ``variables``,
are provided, the ``input`` field in the ``variables``
dict will be overwritten with this value.
variables (dict) - If provided, the "variables" field in GraphQL will be
set to this value.
set to this value.
headers (dict) - If provided, the headers in POST request to GRAPHQL_URL
will be set to this value.
Returns:
Response object from client
"""
body = {"query": query}
if op_name:
body["operation_name"] = op_name
if operation_name:
body["operationName"] = operation_name
if variables:
body["variables"] = variables
if input_data:
@ -51,10 +55,17 @@ class GraphQLTestCase(TestCase):
body["variables"]["input"] = input_data
else:
body["variables"] = {"input": input_data}
resp = self._client.post(
self.GRAPHQL_URL, json.dumps(body), content_type="application/json"
)
if headers:
resp = self._client.post(
self.GRAPHQL_URL,
json.dumps(body),
content_type="application/json",
**headers
)
else:
resp = self._client.post(
self.GRAPHQL_URL, json.dumps(body), content_type="application/json"
)
return resp
def assertResponseNoErrors(self, resp):

View File

@ -2,8 +2,7 @@ import inspect
from django.db import models
from django.db.models.manager import Manager
from django.utils import six
from django.utils.encoding import force_text
from django.utils.encoding import force_str
from django.utils.functional import Promise
from graphene.utils.str_converters import to_camel_case
@ -26,14 +25,14 @@ def isiterable(value):
def _camelize_django_str(s):
if isinstance(s, Promise):
s = force_text(s)
return to_camel_case(s) if isinstance(s, six.string_types) else s
s = force_str(s)
return to_camel_case(s) if isinstance(s, str) else s
def camelize(data):
if isinstance(data, dict):
return {_camelize_django_str(k): camelize(v) for k, v in data.items()}
if isiterable(data) and not isinstance(data, (six.string_types, Promise)):
if isiterable(data) and not isinstance(data, (str, Promise)):
return [camelize(d) for d in data]
return data
@ -77,26 +76,3 @@ def get_model_fields(model):
def is_valid_django_model(model):
return inspect.isclass(model) and issubclass(model, models.Model)
def import_single_dispatch():
try:
from functools import singledispatch
except ImportError:
singledispatch = None
if not singledispatch:
try:
from singledispatch import singledispatch
except ImportError:
pass
if not singledispatch:
raise Exception(
"It seems your python version does not include "
"functools.singledispatch. Please install the 'singledispatch' "
"package. More information here: "
"https://pypi.python.org/pypi/singledispatch"
)
return singledispatch

View File

@ -2,19 +2,19 @@ import inspect
import json
import re
import six
from django.http import HttpResponse, HttpResponseNotAllowed
from django.http.response import HttpResponseBadRequest
from django.shortcuts import render
from django.utils.decorators import method_decorator
from django.views.generic import View
from django.views.decorators.csrf import ensure_csrf_cookie
from graphql import get_default_backend
from graphql.error import format_error as format_graphql_error
from django.views.generic import View
from graphql import OperationType, get_operation_ast, parse, validate
from graphql.error import GraphQLError
from graphql.error import format_error as format_graphql_error
from graphql.execution import ExecutionResult
from graphql.type.schema import GraphQLSchema
from graphene import Schema
from graphql.execution.middleware import MiddlewareManager
from .settings import graphene_settings
@ -57,8 +57,6 @@ class GraphQLView(View):
schema = None
graphiql = False
executor = None
backend = None
middleware = None
root_value = None
pretty = False
@ -67,35 +65,31 @@ class GraphQLView(View):
def __init__(
self,
schema=None,
executor=None,
middleware=None,
root_value=None,
graphiql=False,
pretty=False,
batch=False,
backend=None,
):
if not schema:
schema = graphene_settings.SCHEMA
if backend is None:
backend = get_default_backend()
if middleware is None:
middleware = graphene_settings.MIDDLEWARE
self.schema = self.schema or schema
if middleware is not None:
self.middleware = list(instantiate_middleware(middleware))
self.executor = executor
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.backend = backend
assert isinstance(
self.schema, GraphQLSchema
self.schema, Schema
), "A Schema is required to be provided to GraphQLView."
assert not all((graphiql, batch)), "Use either graphiql or batch processing"
@ -109,9 +103,6 @@ class GraphQLView(View):
def get_context(self, request):
return request
def get_backend(self, request):
return self.backend
@method_decorator(ensure_csrf_cookie)
def dispatch(self, request, *args, **kwargs):
try:
@ -173,7 +164,9 @@ class GraphQLView(View):
self.format_error(e) for e in execution_result.errors
]
if execution_result.invalid:
if execution_result.errors and any(
not e.path for e in execution_result.errors
):
status_code = 400
else:
response["data"] = execution_result.data
@ -246,14 +239,13 @@ class GraphQLView(View):
raise HttpError(HttpResponseBadRequest("Must provide query string."))
try:
backend = self.get_backend(request)
document = backend.document_from_string(self.schema, query)
document = parse(query)
except Exception as e:
return ExecutionResult(errors=[e], invalid=True)
return ExecutionResult(errors=[e])
if request.method.lower() == "get":
operation_type = document.get_operation_type(operation_name)
if operation_type and operation_type != "query":
operation_ast = get_operation_ast(document, operation_name)
if operation_ast and operation_ast.operation != OperationType.QUERY:
if show_graphiql:
return None
@ -261,28 +253,23 @@ class GraphQLView(View):
HttpResponseNotAllowed(
["POST"],
"Can only perform a {} operation from a POST request.".format(
operation_type
operation_ast.operation.value
),
)
)
try:
extra_options = {}
if self.executor:
# We only include it optionally since
# executor is not a valid argument in all backends
extra_options["executor"] = self.executor
validation_errors = validate(self.schema.graphql_schema, document)
if validation_errors:
return ExecutionResult(data=None, errors=validation_errors)
return document.execute(
root=self.get_root_value(request),
variables=variables,
operation_name=operation_name,
context=self.get_context(request),
middleware=self.get_middleware(request),
**extra_options
)
except Exception as e:
return ExecutionResult(errors=[e], invalid=True)
return self.schema.execute(
source=query,
root_value=self.get_root_value(request),
variable_values=variables,
operation_name=operation_name,
context_value=self.get_context(request),
middleware=self.get_middleware(request),
)
@classmethod
def can_display_graphiql(cls, request, data):
@ -314,7 +301,7 @@ class GraphQLView(View):
variables = request.GET.get("variables") or data.get("variables")
id = request.GET.get("id") or data.get("id")
if variables and isinstance(variables, six.text_type):
if variables and isinstance(variables, str):
try:
variables = json.loads(variables)
except Exception:
@ -331,7 +318,7 @@ class GraphQLView(View):
if isinstance(error, GraphQLError):
return format_graphql_error(error)
return {"message": six.text_type(error)}
return {"message": str(error)}
@staticmethod
def get_content_type(request):

View File

@ -1,2 +0,0 @@
[pytest]
DJANGO_SETTINGS_MODULE = django_test_settings

View File

@ -43,3 +43,7 @@ include_trailing_comma=True
force_grid_wrap=0
use_parentheses=True
line_length=88
[tool:pytest]
DJANGO_SETTINGS_MODULE = django_test_settings
addopts = --random-order

View File

@ -1,8 +1,8 @@
from setuptools import find_packages, setup
import sys
import ast
import re
from setuptools import find_packages, setup
_version_re = re.compile(r"__version__\s+=\s+(.*)")
with open("graphene_django/__init__.py", "rb") as f:
@ -16,20 +16,20 @@ rest_framework_require = ["djangorestframework>=3.6.3"]
tests_require = [
"pytest>=3.6.3",
"pytest-cov",
"pytest-random-order",
"coveralls",
"mock",
"pytz",
"django-filter<2;python_version<'3'",
"django-filter>=2;python_version>='3'",
"django-filter>=2",
"pytest-django>=3.3.2",
] + rest_framework_require
dev_requires = [
"black==19.3b0",
"flake8==3.7.7",
"flake8-black==0.1.0",
"flake8-bugbear==19.3.0",
"black==19.10b0",
"flake8==3.7.9",
"flake8-black==0.1.1",
"flake8-bugbear==20.1.4",
] + tests_require
setup(
@ -45,22 +45,18 @@ setup(
"Development Status :: 3 - Alpha",
"Intended Audience :: Developers",
"Topic :: Software Development :: Libraries",
"Programming Language :: Python :: 2",
"Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: Implementation :: PyPy",
],
keywords="api graphql protocol rest relay graphene",
packages=find_packages(exclude=["tests"]),
install_requires=[
"six>=1.10.0",
"graphene>=2.1.7,<3",
"graphql-core>=2.1.0,<3",
"Django>=1.11",
"singledispatch>=3.4.0.3",
"graphene>=3.0.0b1,<4",
"graphql-core>=3.1.0,<4",
"Django>=2.2",
"promise>=2.1",
],
setup_requires=["pytest-runner"],

26
tox.ini
View File

@ -1,14 +1,18 @@
[tox]
envlist =
py{27,35,36,37}-django{111,20,21,22,master},
py{36,37,38}-django{22,30,master},
black,flake8
[travis:env]
[gh-actions]
python =
3.6: py36
3.7: py37
3.8: py38
[gh-actions:env]
DJANGO =
1.11: django111
2.0: django20
2.1: django21
2.2: django22
3.0: django30
master: djangomaster
[testenv]
@ -18,22 +22,20 @@ setenv =
DJANGO_SETTINGS_MODULE=django_test_settings
deps =
-e.[test]
psycopg2
django111: Django>=1.11,<2.0
django20: Django>=2.0,<2.1
django21: Django>=2.1,<2.2
psycopg2-binary
django22: Django>=2.2,<3.0
django30: Django>=3.0a1,<3.1
djangomaster: https://github.com/django/django/archive/master.zip
commands = {posargs:py.test --cov=graphene_django graphene_django examples}
[testenv:black]
basepython = python3.7
basepython = python3.8
deps = -e.[dev]
commands =
black --exclude "/migrations/" graphene_django examples setup.py --check
[testenv:flake8]
basepython = python3.7
basepython = python3.8
deps = -e.[dev]
commands =
flake8 graphene_django examples
flake8 graphene_django examples setup.py