Merge branch 'master' of github.com:tpict/graphene-django into connection-offset

This commit is contained in:
Tom Picton 2020-08-12 11:15:20 -07:00
commit 1eee5a42e9
66 changed files with 3073 additions and 891 deletions

34
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,34 @@
---
name: Bug report
about: Create a report to help us improve
title: ''
labels: "\U0001F41Bbug"
assignees: ''
---
**Note: for support questions, please use stackoverflow**. This repository's issues are reserved for feature requests and bug reports.
* **What is the current behavior?**
* **If the current behavior is a bug, please provide the steps to reproduce and if possible a minimal demo of the problem** via
a github repo, https://repl.it or similar (you can use this template as a starting point: https://repl.it/@jkimbo/Graphene-Django-Example).
* **What is the expected behavior?**
* **What is the motivation / use case for changing the behavior?**
* **Please tell us about your environment:**
- Version:
- Platform:
* **Other information** (e.g. detailed explanation, stacktraces, related issues, suggestions how to fix, links for us to have context, eg. stackoverflow)

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1 @@
blank_issues_enabled: false

View File

@ -0,0 +1,20 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: "✨enhancement"
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

8
.github/stale.yml vendored
View File

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

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

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

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
---
# ![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)
A [Django](https://www.djangoproject.com/) integration for [Graphene](http://graphene-python.org/). 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 ## Documentation
[Visit the documentation to get started!](https://docs.graphene-python.org/projects/django/en/latest/) [Visit the documentation to get started!](https://docs.graphene-python.org/projects/django/en/latest/)
@ -78,7 +90,7 @@ class Query(graphene.ObjectType):
schema = graphene.Schema(query=Query) schema = graphene.Schema(query=Query)
``` ```
Then you can simply query the schema: Then you can query the schema:
```python ```python
query = ''' query = '''
@ -101,3 +113,7 @@ To learn more check out the following [examples](examples/):
## Contributing ## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) See [CONTRIBUTING.md](CONTRIBUTING.md)
## Release Notes
* See [Releases page on github](https://github.com/graphql-python/graphene-django/releases)

View File

@ -20,7 +20,7 @@ Let's use a simple example model.
Limiting Field Access Limiting Field Access
--------------------- ---------------------
To limit fields in a GraphQL query simply use the ``only_fields`` meta attribute. To limit fields in a GraphQL query simply use the ``fields`` meta attribute.
.. code:: python .. code:: python
@ -31,10 +31,10 @@ To limit fields in a GraphQL query simply use the ``only_fields`` meta attribute
class PostNode(DjangoObjectType): class PostNode(DjangoObjectType):
class Meta: class Meta:
model = Post model = Post
only_fields = ('title', 'content') fields = ('title', 'content')
interfaces = (relay.Node, ) interfaces = (relay.Node, )
conversely you can use ``exclude_fields`` meta attribute. conversely you can use ``exclude`` meta attribute.
.. code:: python .. code:: python
@ -45,7 +45,7 @@ conversely you can use ``exclude_fields`` meta attribute.
class PostNode(DjangoObjectType): class PostNode(DjangoObjectType):
class Meta: class Meta:
model = Post model = Post
exclude_fields = ('published', 'owner') exclude = ('published', 'owner')
interfaces = (relay.Node, ) interfaces = (relay.Node, )
Queryset Filtering On Lists Queryset Filtering On Lists
@ -84,7 +84,7 @@ with the context argument.
def resolve_my_posts(self, info): def resolve_my_posts(self, info):
# context will reference to the Django request # context will reference to the Django request
if not info.context.user.is_authenticated(): if not info.context.user.is_authenticated:
return Post.objects.none() return Post.objects.none()
else: else:
return Post.objects.filter(owner=info.context.user) return Post.objects.filter(owner=info.context.user)
@ -133,7 +133,7 @@ method to your ``DjangoObjectType``.
class PostNode(DjangoObjectType): class PostNode(DjangoObjectType):
class Meta: class Meta:
model = Post model = Post
only_fields = ('title', 'content') fields = ('title', 'content')
interfaces = (relay.Node, ) interfaces = (relay.Node, )
@classmethod @classmethod
@ -166,7 +166,7 @@ To restrict users from accessing the GraphQL API page the standard Django LoginR
After this, you can use the new ``PrivateGraphQLView`` in the project's URL Configuration file ``url.py``: After this, you can use the new ``PrivateGraphQLView`` in the project's URL Configuration file ``url.py``:
For Django 1.9 and below: For Django 1.11:
.. code:: python .. code:: python

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 queryset 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

@ -1,7 +1,7 @@
Filtering Filtering
========= =========
Graphene integrates with Graphene-Django integrates with
`django-filter <https://django-filter.readthedocs.io/en/master/>`__ (2.x for `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 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>`__ documentation <https://django-filter.readthedocs.io/en/master/guide/usage.html#the-filter>`__
@ -14,9 +14,18 @@ You will need to install it manually, which can be done as follows:
.. code:: bash .. code:: bash
# You'll need to django-filter # You'll need to install django-filter
pip install django-filter>=2 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 Note: The techniques below are demoed in the `cookbook example
app <https://github.com/graphql-python/graphene-django/tree/master/examples/cookbook>`__. app <https://github.com/graphql-python/graphene-django/tree/master/examples/cookbook>`__.
@ -127,7 +136,7 @@ create your own ``FilterSet``. You can pass it directly as follows:
all_animals = DjangoFilterConnectionField(AnimalNode, all_animals = DjangoFilterConnectionField(AnimalNode,
filterset_class=AnimalFilter) filterset_class=AnimalFilter)
You can also specify the ``FilterSet`` class using the ``filerset_class`` You can also specify the ``FilterSet`` class using the ``filterset_class``
parameter when defining your ``DjangoObjectType``, however, this can't be used parameter when defining your ``DjangoObjectType``, however, this can't be used
in unison with the ``filter_fields`` parameter: in unison with the ``filter_fields`` parameter:

View File

@ -9,8 +9,8 @@ Graphene-Django provides some additional abstractions that make it easy to add G
First time? We recommend you start with the installation guide to get set up and the basic tutorial. First time? We recommend you start with the installation guide to get set up and the basic tutorial.
It is worth reading the `core graphene docs <https://docs.graphene-python.org/en/latest/>`__ to familiarize yourself with the basic utilities. It is worth reading the `core graphene docs <https://docs.graphene-python.org/en/latest/>`__ to familiarize yourself with the basic utilities.
Core tenants Core tenets
------------ -----------
If you want to expose your data through GraphQL - read the ``Installation``, ``Schema`` and ``Queries`` section. If you want to expose your data through GraphQL - read the ``Installation``, ``Schema`` and ``Queries`` section.
@ -25,7 +25,10 @@ For more advanced use, check out the Relay tutorial.
tutorial-relay tutorial-relay
schema schema
queries queries
fields
extra-types
mutations mutations
subscriptions
filtering filtering
authorization authorization
debug debug

View File

@ -8,7 +8,7 @@ Requirements
Graphene-Django currently supports the following versions of Django: Graphene-Django currently supports the following versions of Django:
* Django 2.X * >= Django 1.11
Installation Installation
------------ ------------
@ -25,13 +25,27 @@ Add ``graphene_django`` to the ``INSTALLED_APPS`` in the ``settings.py`` file of
INSTALLED_APPS = [ INSTALLED_APPS = [
... ...
'django.contrib.staticfiles', # Required for GraphiQL "django.contrib.staticfiles", # Required for GraphiQL
'graphene_django' "graphene_django"
] ]
We need to add a ``graphql`` URL to the ``urls.py`` of your Django project: We need to add a ``graphql`` URL to the ``urls.py`` of your Django project:
For Django 1.11:
.. code:: python
from django.conf.urls import url
from graphene_django.views import GraphQLView
urlpatterns = [
# ...
url(r"graphql", GraphQLView.as_view(graphiql=True)),
]
For Django 2.0 and above:
.. code:: python .. code:: python
from django.urls import path from django.urls import path
@ -49,7 +63,7 @@ Finally, define the schema location for Graphene in the ``settings.py`` file of
.. code:: python .. code:: python
GRAPHENE = { GRAPHENE = {
'SCHEMA': 'django_root.schema.schema' "SCHEMA": "django_root.schema.schema"
} }
Where ``path.schema.schema`` is the location of the ``Schema`` object in your Django project. Where ``path.schema.schema`` is the location of the ``Schema`` object in your Django project.
@ -61,9 +75,31 @@ The most basic ``schema.py`` looks like this:
import graphene import graphene
class Query(graphene.ObjectType): class Query(graphene.ObjectType):
pass hello = graphene.String(default_value="Hi!")
schema = graphene.Schema(query=Query) 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. 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 Advanced Usage
-------------- --------------
@ -44,7 +58,8 @@ specify the parameters in your settings.py:
GRAPHENE = { GRAPHENE = {
'SCHEMA': 'tutorial.quickstart.schema', '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

@ -44,7 +44,7 @@ Simple example
return QuestionMutation(question=question) return QuestionMutation(question=question)
class Mutation: class Mutation(graphene.ObjectType):
update_question = QuestionMutation.Field() update_question = QuestionMutation.Field()

View File

@ -1,3 +1,5 @@
.. _queries-objecttypes:
Queries & ObjectTypes Queries & ObjectTypes
===================== =====================
@ -18,27 +20,26 @@ Full example
# my_app/schema.py # my_app/schema.py
import graphene import graphene
from graphene_django import DjangoObjectType
from graphene_django.types import DjangoObjectType
from .models import Question from .models import Question
class QuestionType(DjangoObjectType): class QuestionType(DjangoObjectType):
class Meta: class Meta:
model = Question model = Question
fields = ("id", "question_text")
class Query(graphene.ObjectType):
class Query:
questions = graphene.List(QuestionType) questions = graphene.List(QuestionType)
question = graphene.Field(QuestionType, question_id=graphene.String()) question_by_id = graphene.Field(QuestionType, id=graphene.String())
def resolve_questions(self, info, **kwargs): def resolve_questions(root, info, **kwargs):
# Querying a list # Querying a list
return Question.objects.all() return Question.objects.all()
def resolve_question(self, info, question_id): def resolve_question_by_id(root, info, id):
# Querying a single question # Querying a single question
return Question.objects.get(pk=question_id) return Question.objects.get(pk=id)
Specifying which fields to include Specifying which fields to include
@ -58,21 +59,27 @@ Show **only** these fields on the model:
.. code:: python .. code:: python
from graphene_django import DjangoObjectType
from .models import Question
class QuestionType(DjangoObjectType): class QuestionType(DjangoObjectType):
class Meta: class Meta:
model = Question model = Question
fields = ('id', 'question_text') fields = ("id", "question_text")
You can also set the ``fields`` attribute to the special value ``'__all__'`` to indicate that all fields in the model should be used. You can also set the ``fields`` attribute to the special value ``"__all__"`` to indicate that all fields in the model should be used.
For example: For example:
.. code:: python .. code:: python
from graphene_django import DjangoObjectType
from .models import Question
class QuestionType(DjangoObjectType): class QuestionType(DjangoObjectType):
class Meta: class Meta:
model = Question model = Question
fields = '__all__' fields = "__all__"
``exclude`` ``exclude``
@ -82,10 +89,13 @@ Show all fields **except** those in ``exclude``:
.. code:: python .. code:: python
from graphene_django import DjangoObjectType
from .models import Question
class QuestionType(DjangoObjectType): class QuestionType(DjangoObjectType):
class Meta: class Meta:
model = Question model = Question
exclude = ('question_text',) exclude = ("question_text",)
Customising fields Customising fields
@ -95,16 +105,19 @@ You can completely overwrite a field, or add new fields, to a ``DjangoObjectType
.. code:: python .. code:: python
from graphene_django import DjangoObjectType
from .models import Question
class QuestionType(DjangoObjectType): class QuestionType(DjangoObjectType):
class Meta: class Meta:
model = Question model = Question
fields = ('id', 'question_text') fields = ("id", "question_text")
extra_field = graphene.String() extra_field = graphene.String()
def resolve_extra_field(self, info): def resolve_extra_field(self, info):
return 'hello!' return "hello!"
Choices to Enum conversion Choices to Enum conversion
@ -119,12 +132,19 @@ For example the following ``Model`` and ``DjangoObjectType``:
.. code:: python .. code:: python
from django.db import models
from graphene_django import DjangoObjectType
class PetModel(models.Model): class PetModel(models.Model):
kind = models.CharField(max_length=100, choices=(('cat', 'Cat'), ('dog', 'Dog'))) kind = models.CharField(
max_length=100,
choices=(("cat", "Cat"), ("dog", "Dog"))
)
class Pet(DjangoObjectType): class Pet(DjangoObjectType):
class Meta: class Meta:
model = PetModel model = PetModel
fields = ("id", "kind",)
Results in the following GraphQL schema definition: Results in the following GraphQL schema definition:
@ -146,9 +166,13 @@ You can disable this automatic conversion by setting
.. code:: python .. code:: python
from graphene_django import DjangoObjectType
from .models import PetModel
class Pet(DjangoObjectType): class Pet(DjangoObjectType):
class Meta: class Meta:
model = PetModel model = PetModel
fields = ("id", "kind",)
convert_choices_to_enum = False convert_choices_to_enum = False
.. code:: .. code::
@ -163,10 +187,14 @@ automatically converted into enums:
.. code:: python .. code:: python
from graphene_django import DjangoObjectType
from .models import PetModel
class Pet(DjangoObjectType): class Pet(DjangoObjectType):
class Meta: class Meta:
model = PetModel model = PetModel
convert_choices_to_enum = ['kind'] fields = ("id", "kind",)
convert_choices_to_enum = ["kind"]
**Note:** Setting ``convert_choices_to_enum = []`` is the same as setting it to **Note:** Setting ``convert_choices_to_enum = []`` is the same as setting it to
``False``. ``False``.
@ -179,6 +207,8 @@ Say you have the following models:
.. code:: python .. code:: python
from django.db import models
class Category(models.Model): class Category(models.Model):
foo = models.CharField(max_length=256) foo = models.CharField(max_length=256)
@ -190,10 +220,13 @@ When ``Question`` is published as a ``DjangoObjectType`` and you want to add ``C
.. code:: python .. code:: python
from graphene_django import DjangoObjectType
from .models import Question
class QuestionType(DjangoObjectType): class QuestionType(DjangoObjectType):
class Meta: class Meta:
model = Question model = Question
fields = ('category',) fields = ("category",)
Then all query-able related models must be defined as DjangoObjectType subclass, Then all query-able related models must be defined as DjangoObjectType subclass,
or they will fail to show if you are trying to query those relation fields. You only or they will fail to show if you are trying to query those relation fields. You only
@ -201,9 +234,15 @@ need to create the most basic class for this to work:
.. code:: python .. code:: python
from graphene_django import DjangoObjectType
from .models import Category
class CategoryType(DjangoObjectType): class CategoryType(DjangoObjectType):
class Meta: class Meta:
model = Category model = Category
fields = ("foo",)
.. _django-objecttype-get-queryset:
Default QuerySet Default QuerySet
----------------- -----------------
@ -216,7 +255,6 @@ Use this to control filtering on the ObjectType level instead of the Query objec
from graphene_django.types import DjangoObjectType from graphene_django.types import DjangoObjectType
from .models import Question from .models import Question
class QuestionType(DjangoObjectType): class QuestionType(DjangoObjectType):
class Meta: class Meta:
model = Question model = Question
@ -236,18 +274,22 @@ This resolve method should follow this format:
.. code:: python .. code:: python
def resolve_foo(self, info, **kwargs): def resolve_foo(parent, info, **kwargs):
Where "foo" is the name of the field declared in the ``Query`` object. Where "foo" is the name of the field declared in the ``Query`` object.
.. code:: python .. code:: python
class Query: import graphene
from .models import Question
from .types import QuestionType
class Query(graphene.ObjectType):
foo = graphene.List(QuestionType) foo = graphene.List(QuestionType)
def resolve_foo(self, info, **kwargs): def resolve_foo(root, info):
id = kwargs.get('id') id = kwargs.get("id")
return QuestionModel.objects.get(id) return Question.objects.get(id)
Arguments Arguments
~~~~~~~~~ ~~~~~~~~~
@ -256,10 +298,18 @@ Additionally, Resolvers will receive **any arguments declared in the field defin
.. code:: python .. code:: python
class Query: import graphene
question = graphene.Field(Question, foo=graphene.String(), bar=graphene.Int()) from .models import Question
from .types import QuestionType
def resolve_question(self, info, foo, bar): class Query(graphene.ObjectType):
question = graphene.Field(
QuestionType,
foo=graphene.String(),
bar=graphene.Int()
)
def resolve_question(root, info, foo, bar):
# If `foo` or `bar` are declared in the GraphQL query they will be here, else None. # If `foo` or `bar` are declared in the GraphQL query they will be here, else None.
return Question.objects.filter(foo=foo, bar=bar).first() return Question.objects.filter(foo=foo, bar=bar).first()
@ -274,7 +324,15 @@ of Django's ``HTTPRequest`` in your resolve methods, such as checking for authen
.. code:: python .. code:: python
def resolve_questions(self, info, **kwargs): import graphene
from .models import Question
from .types import QuestionType
class Query(graphene.ObjectType):
questions = graphene.List(QuestionType)
def resolve_questions(root, info):
# See if a user is authenticated # See if a user is authenticated
if info.context.user.is_authenticated(): if info.context.user.is_authenticated():
return Question.objects.all() return Question.objects.all()
@ -282,6 +340,13 @@ of Django's ``HTTPRequest`` in your resolve methods, such as checking for authen
return Question.objects.none() 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 Plain ObjectTypes
----------------- -----------------
@ -294,15 +359,13 @@ Django models and your external API.
import graphene import graphene
from .models import Question from .models import Question
class MyQuestion(graphene.ObjectType): class MyQuestion(graphene.ObjectType):
text = graphene.String() text = graphene.String()
class Query(graphene.ObjectType):
class Query:
question = graphene.Field(MyQuestion, question_id=graphene.String()) question = graphene.Field(MyQuestion, question_id=graphene.String())
def resolve_question(self, info, question_id): def resolve_question(root, info, question_id):
question = Question.objects.get(pk=question_id) question = Question.objects.get(pk=question_id)
return MyQuestion( return MyQuestion(
text=question.question_text text=question.question_text
@ -332,25 +395,22 @@ the core graphene pages for more information on customizing the Relay experience
from graphene_django import DjangoObjectType from graphene_django import DjangoObjectType
from .models import Question from .models import Question
class QuestionType(DjangoObjectType): class QuestionType(DjangoObjectType):
class Meta: class Meta:
model = Question model = Question
interfaces = (relay.Node,) interfaces = (relay.Node,) # make sure you add this
fields = "__all__"
class QuestionConnection(relay.Connection): class QuestionConnection(relay.Connection):
class Meta: class Meta:
node = QuestionType node = QuestionType
class Query: class Query:
questions = relay.ConnectionField(QuestionConnection) questions = relay.ConnectionField(QuestionConnection)
def resolve_questions(root, info, **kwargs): def resolve_questions(root, info, **kwargs):
return Question.objects.all() return Question.objects.all()
You can now execute queries like: You can now execute queries like:

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

@ -104,7 +104,7 @@ Default: ``100``
``CAMELCASE_ERRORS`` ``CAMELCASE_ERRORS``
------------------------------------ --------------------
When set to ``True`` field names in the ``errors`` object will be camel case. When set to ``True`` field names in the ``errors`` object will be camel case.
By default they will be snake case. By default they will be snake case.
@ -140,3 +140,70 @@ Default: ``False``
# 'messages': ['This field is required.'], # '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"
}
``SUBSCRIPTION_PATH``
---------------------
Define an alternative URL path where subscription operations should be routed.
The GraphiQL interface will use this setting to intelligently route subscription operations. This is useful if you have more advanced infrastructure requirements that prevent websockets from being handled at the same path (e.g., a WSGI server listening at ``/graphql`` and an ASGI server listening at ``/ws/graphql``).
Default: ``None``
.. code:: python
GRAPHENE = {
'SUBSCRIPTION_PATH': "/ws/graphql"
}
``GRAPHIQL_HEADER_EDITOR_ENABLED``
---------------------
GraphiQL starting from version 1.0.0 allows setting custom headers in similar fashion to query variables.
Set to ``False`` if you want to disable GraphiQL headers editor tab for some reason.
This setting is passed to ``headerEditorEnabled`` GraphiQL options, for details refer to GraphiQLDocs_.
.. _GraphiQLDocs: https://github.com/graphql/graphiql/tree/main/packages/graphiql#options
Default: ``True``
.. code:: python
GRAPHENE = {
'GRAPHIQL_HEADER_EDITOR_ENABLED': True,
}

42
docs/subscriptions.rst Normal file
View File

@ -0,0 +1,42 @@
Subscriptions
=============
The ``graphene-django`` project does not currently support GraphQL subscriptions out of the box. However, there are
several community-driven modules for adding subscription support, and the provided GraphiQL interface supports
running subscription operations over a websocket.
To implement websocket-based support for GraphQL subscriptions, youll need to do the following:
1. Install and configure `django-channels <https://channels.readthedocs.io/en/latest/installation.html>`_.
2. Install and configure* a third-party module for adding subscription support over websockets. A few options include:
- `graphql-python/graphql-ws <https://github.com/graphql-python/graphql-ws>`_
- `datavance/django-channels-graphql-ws <https://github.com/datadvance/DjangoChannelsGraphqlWs>`_
- `jaydenwindle/graphene-subscriptions <https://github.com/jaydenwindle/graphene-subscriptions>`_
3. Ensure that your application (or at least your GraphQL endpoint) is being served via an ASGI protocol server like
daphne (built in to ``django-channels``), `uvicorn <https://www.uvicorn.org/>`_, or
`hypercorn <https://pgjones.gitlab.io/hypercorn/>`_.
..
*** Note:** By default, the GraphiQL interface that comes with
``graphene-django`` assumes that you are handling subscriptions at
the same path as any other operation (i.e., you configured both
``urls.py`` and ``routing.py`` to handle GraphQL operations at the
same path, like ``/graphql``).
If these URLs differ, GraphiQL will try to run your subscription over
HTTP, which will produce an error. If you need to use a different URL
for handling websocket connections, you can configure
``SUBSCRIPTION_PATH`` in your ``settings.py``:
.. code:: python
GRAPHENE = {
# ...
"SUBSCRIPTION_PATH": "/ws/graphql" # The path you configured in `routing.py`, including a leading slash.
}
Once your application is properly configured to handle subscriptions, you can use the GraphiQL interface to test
subscriptions like any other operation.

View File

@ -1,8 +1,13 @@
Testing API calls with django Testing API calls with django
============================= =============================
Using unittest
--------------
If you want to unittest your API calls derive your test case from the class `GraphQLTestCase`. 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: Usage:
.. code:: python .. code:: python
@ -10,12 +15,8 @@ Usage:
import json import json
from graphene_django.utils.testing import GraphQLTestCase from graphene_django.utils.testing import GraphQLTestCase
from my_project.config.schema import schema
class MyFancyTestCase(GraphQLTestCase): class MyFancyTestCase(GraphQLTestCase):
# Here you need to inject your test case's schema
GRAPHQL_SCHEMA = schema
def test_some_query(self): def test_some_query(self):
response = self.query( response = self.query(
''' '''
@ -37,6 +38,28 @@ Usage:
# Add some more asserts if you like # Add some more asserts if you like
... ...
def test_query_with_variables(self):
response = self.query(
'''
query myModel($id: Int!){
myModel(id: $id) {
id
name
}
}
''',
op_name='myModel',
variables={'id': 1}
)
content = json.loads(response.content)
# This validates the status code and if you get errors
self.assertResponseNoErrors(response)
# Add some more asserts if you like
...
def test_some_mutation(self): def test_some_mutation(self):
response = self.query( response = self.query(
''' '''
@ -58,3 +81,38 @@ Usage:
# Add some more asserts if you like # Add some more asserts if you like
... ...
Using pytest
------------
To use pytest define a simple fixture using the query helper below
.. code:: python
# Create a fixture using the graphql_query helper and `client` fixture from `pytest-django`.
import pytest
from graphene_django.utils.testing import graphql_query
@pytest.fixture
def client_query(client)
def func(*args, **kwargs):
return graphql_query(*args, **kwargs, client=client)
return func
# Test you query using the client_query fixture
def test_some_query(client_query):
response = graphql_query(
'''
query {
myModel {
id
name
}
}
''',
op_name='myModel'
)
content = json.loads(response.content)
assert 'errors' not in content

View File

@ -3,15 +3,11 @@ Basic Tutorial
Graphene Django has a number of additional features that are designed to make Graphene Django has a number of additional features that are designed to make
working with Django easy. Our primary focus in this tutorial is to give a good working with Django easy. Our primary focus in this tutorial is to give a good
understanding of how to connect models from Django ORM to graphene object types. understanding of how to connect models from Django ORM to Graphene object types.
Set up the Django project Set up the Django project
------------------------- -------------------------
You can find the entire project in ``examples/cookbook-plain``.
----
We will set up the project, create the following: We will set up the project, create the following:
- A Django project called ``cookbook`` - A Django project called ``cookbook``
@ -28,13 +24,12 @@ We will set up the project, create the following:
source env/bin/activate # On Windows use `env\Scripts\activate` source env/bin/activate # On Windows use `env\Scripts\activate`
# Install Django and Graphene with Django support # Install Django and Graphene with Django support
pip install django pip install django graphene_django
pip install graphene_django
# Set up a new project with a single application # Set up a new project with a single application
django-admin.py startproject cookbook . # Note the trailing '.' character django-admin startproject cookbook . # Note the trailing '.' character
cd cookbook cd cookbook
django-admin.py startapp ingredients django-admin startapp ingredients
Now sync your database for the first time: Now sync your database for the first time:
@ -54,19 +49,18 @@ Let's get started with these models:
# cookbook/ingredients/models.py # cookbook/ingredients/models.py
from django.db import models from django.db import models
class Category(models.Model): class Category(models.Model):
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
def __str__(self): def __str__(self):
return self.name return self.name
class Ingredient(models.Model): class Ingredient(models.Model):
name = models.CharField(max_length=100) name = models.CharField(max_length=100)
notes = models.TextField() notes = models.TextField()
category = models.ForeignKey( category = models.ForeignKey(
Category, related_name='ingredients', on_delete=models.CASCADE) Category, related_name="ingredients", on_delete=models.CASCADE
)
def __str__(self): def __str__(self):
return self.name return self.name
@ -75,10 +69,12 @@ Add ingredients as INSTALLED_APPS:
.. code:: python .. code:: python
# cookbook/settings.py
INSTALLED_APPS = [ INSTALLED_APPS = [
... ...
# Install the ingredients app # Install the ingredients app
'cookbook.ingredients', "cookbook.ingredients",
] ]
@ -102,13 +98,13 @@ following:
.. code:: bash .. code:: bash
$ python ./manage.py loaddata ingredients python manage.py loaddata ingredients
Installed 6 object(s) from 1 fixture(s) Installed 6 object(s) from 1 fixture(s)
Alternatively you can use the Django admin interface to create some data Alternatively you can use the Django admin interface to create some data
yourself. You'll need to run the development server (see below), and yourself. You'll need to run the development server (see below), and
create a login for yourself too (``./manage.py createsuperuser``). create a login for yourself too (``python manage.py createsuperuser``).
Register models with admin panel: Register models with admin panel:
@ -138,66 +134,48 @@ order to create this representation, Graphene needs to know about each
This graph also has a *root type* through which all access begins. This This graph also has a *root type* through which all access begins. This
is the ``Query`` class below. is the ``Query`` class below.
This means, for each of our models, we are going to create a type, subclassing ``DjangoObjectType`` To create GraphQL types for each of our Django models, we are going to subclass the ``DjangoObjectType`` class which will automatically define GraphQL fields that correspond to the fields on the Django models.
After we've done that, we will list those types as fields in the ``Query`` class. After we've done that, we will list those types as fields in the ``Query`` class.
Create ``cookbook/ingredients/schema.py`` and type the following: Create ``cookbook/schema.py`` and type the following:
.. code:: python .. code:: python
# cookbook/ingredients/schema.py # cookbook/schema.py
import graphene import graphene
from graphene_django import DjangoObjectType
from graphene_django.types import DjangoObjectType
from cookbook.ingredients.models import Category, Ingredient from cookbook.ingredients.models import Category, Ingredient
class CategoryType(DjangoObjectType): class CategoryType(DjangoObjectType):
class Meta: class Meta:
model = Category model = Category
fields = ("id", "name", "ingredients")
class IngredientType(DjangoObjectType): class IngredientType(DjangoObjectType):
class Meta: class Meta:
model = Ingredient model = Ingredient
fields = ("id", "name", "notes", "category")
class Query(graphene.ObjectType):
class Query(object):
all_categories = graphene.List(CategoryType)
all_ingredients = graphene.List(IngredientType) all_ingredients = graphene.List(IngredientType)
category_by_name = graphene.Field(CategoryType, name=graphene.String(required=True))
def resolve_all_categories(self, info, **kwargs): def resolve_all_ingredients(root, info):
return Category.objects.all()
def resolve_all_ingredients(self, info, **kwargs):
# We can easily optimize query count in the resolve method # We can easily optimize query count in the resolve method
return Ingredient.objects.select_related('category').all() return Ingredient.objects.select_related("category").all()
def resolve_category_by_name(root, info, name):
Note that the above ``Query`` class is a mixin, inheriting from try:
``object``. This is because we will now create a project-level query return Category.objects.get(name=name)
class which will combine all our app-level mixins. except Category.DoesNotExist:
return None
Create the parent project-level ``cookbook/schema.py``:
.. code:: python
import graphene
import cookbook.ingredients.schema
class Query(cookbook.ingredients.schema.Query, graphene.ObjectType):
# This class will inherit from multiple Queries
# as we begin to add more apps to our project
pass
schema = graphene.Schema(query=Query) schema = graphene.Schema(query=Query)
You can think of this as being something like your top-level ``urls.py`` You can think of this as being something like your top-level ``urls.py``
file (although it currently lacks any namespacing). file.
Testing everything so far Testing everything so far
------------------------- -------------------------
@ -216,18 +194,21 @@ Add ``graphene_django`` to ``INSTALLED_APPS`` in ``cookbook/settings.py``:
.. code:: python .. code:: python
# cookbook/settings.py
INSTALLED_APPS = [ INSTALLED_APPS = [
... ...
# This will also make the `graphql_schema` management command available "graphene_django",
'graphene_django',
] ]
And then add the ``SCHEMA`` to the ``GRAPHENE`` config in ``cookbook/settings.py``: And then add the ``SCHEMA`` to the ``GRAPHENE`` config in ``cookbook/settings.py``:
.. code:: python .. code:: python
# cookbook/settings.py
GRAPHENE = { GRAPHENE = {
'SCHEMA': 'cookbook.schema.schema' "SCHEMA": "cookbook.schema.schema"
} }
Alternatively, we can specify the schema to be used in the urls definition, Alternatively, we can specify the schema to be used in the urls definition,
@ -245,14 +226,17 @@ aforementioned GraphiQL we specify that on the parameters with ``graphiql=True``
.. code:: python .. code:: python
from django.conf.urls import url, include # cookbook/urls.py
from django.contrib import admin from django.contrib import admin
from django.urls import path
from django.views.decorators.csrf import csrf_exempt
from graphene_django.views import GraphQLView from graphene_django.views import GraphQLView
urlpatterns = [ urlpatterns = [
url(r'^admin/', admin.site.urls), path("admin/", admin.site.urls),
url(r'^graphql$', GraphQLView.as_view(graphiql=True)), path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True))),
] ]
@ -261,16 +245,19 @@ as explained above, we can do so here using:
.. code:: python .. code:: python
from django.conf.urls import url, include # cookbook/urls.py
from django.contrib import admin from django.contrib import admin
from django.urls import path
from django.views.decorators.csrf import csrf_exempt
from graphene_django.views import GraphQLView from graphene_django.views import GraphQLView
from cookbook.schema import schema from cookbook.schema import schema
urlpatterns = [ urlpatterns = [
url(r'^admin/', admin.site.urls), path("admin/", admin.site.urls),
url(r'^graphql$', GraphQLView.as_view(graphiql=True, schema=schema)), path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema))),
] ]
@ -283,10 +270,10 @@ from the command line.
.. code:: bash .. code:: bash
$ python ./manage.py runserver python manage.py runserver
Performing system checks... Performing system checks...
Django version 1.9, using settings 'cookbook.settings' Django version 3.0.7, using settings 'cookbook.settings'
Starting development server at http://127.0.0.1:8000/ Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C. Quit the server with CONTROL-C.
@ -329,24 +316,25 @@ If you are using the provided fixtures, you will see the following response:
} }
} }
You can experiment with ``allCategories`` too.
Something to have in mind is the `auto camelcasing <http://docs.graphene-python.org/en/latest/types/schema/#auto-camelcase-field-names>`__ that is happening. Congratulations, you have created a working GraphQL server 🥳!
Note: Graphene `automatically camelcases <http://docs.graphene-python.org/en/latest/types/schema/#auto-camelcase-field-names>`__ all field names for better compatibility with JavaScript clients.
Getting relations Getting relations
----------------- -----------------
Right now, with this simple setup in place, we can query for relations too. This is where graphql becomes really powerful! Using the current schema we can query for relations too. This is where GraphQL becomes really powerful!
For example, we may want to list all categories and in each category, all ingredients that are in that category. For example, we may want to get a specific categories and list all ingredients that are in that category.
We can do that with the following query: We can do that with the following query:
.. code:: .. code::
query { query {
allCategories { categoryByName(name: "Dairy") {
id id
name name
ingredients { ingredients {
@ -356,15 +344,13 @@ We can do that with the following query:
} }
} }
This will give you (in case you are using the fixtures) the following result: This will give you (in case you are using the fixtures) the following result:
.. code:: .. code::
{ {
"data": { "data": {
"allCategories": [ "categoryByName": {
{
"id": "1", "id": "1",
"name": "Dairy", "name": "Dairy",
"ingredients": [ "ingredients": [
@ -377,22 +363,7 @@ This will give you (in case you are using the fixtures) the following result:
"name": "Milk" "name": "Milk"
} }
] ]
},
{
"id": "2",
"name": "Meat",
"ingredients": [
{
"id": "3",
"name": "Beef"
},
{
"id": "4",
"name": "Chicken"
} }
]
}
]
} }
} }
@ -411,128 +382,12 @@ We can also list all ingredients and get information for the category they are i
} }
} }
Getting single objects
----------------------
So far, we have been able to fetch list of objects and follow relation. But what about single objects?
We can update our schema to support that, by adding new query for ``ingredient`` and ``category`` and adding arguments, so we can query for specific objects.
.. 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
Now, with the code in place, we can query for single objects.
For example, lets query ``category``:
.. code::
query {
category(id: 1) {
name
}
anotherCategory: category(name: "Dairy") {
ingredients {
id
name
}
}
}
This will give us the following results:
.. code::
{
"data": {
"category": {
"name": "Dairy"
},
"anotherCategory": {
"ingredients": [
{
"id": "1",
"name": "Eggs"
},
{
"id": "2",
"name": "Milk"
}
]
}
}
}
As an exercise, you can try making some queries to ``ingredient``.
Something to keep in mind - since we are using one field several times in our query, we need `aliases <http://graphql.org/learn/queries/#aliases>`__
Summary Summary
------- -------
As you can see, GraphQL is very powerful but there are a lot of repetitions in our example. We can do a lot of improvements by adding layers of abstraction on top of ``graphene-django``. As you can see, GraphQL is very powerful and integrating Django models allows you to get started with a working server quickly.
If you want to put things like ``django-filter`` and automatic pagination in action, you should continue with the **relay tutorial.** If you want to put things like ``django-filter`` and automatic pagination in action, you should continue with the :ref:`Relay tutorial`.
A good idea is to check the `graphene <http://docs.graphene-python.org/en/latest/>`__ A good idea is to check the `Graphene <http://docs.graphene-python.org/en/latest/>`__
documentation but it is not essential to understand and use Graphene-Django in your project. documentation so that you are familiar with it as well.

View File

@ -1,3 +1,5 @@
.. _Relay tutorial:
Relay tutorial Relay tutorial
======================================== ========================================
@ -277,7 +279,7 @@ from the command line.
$ python ./manage.py runserver $ python ./manage.py runserver
Performing system checks... 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/ Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C. Quit the server with CONTROL-C.

View File

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

View File

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

View File

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

View File

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

View File

@ -8,8 +8,14 @@ try:
from django.contrib.postgres.fields import ( from django.contrib.postgres.fields import (
ArrayField, ArrayField,
HStoreField, HStoreField,
JSONField, JSONField as PGJSONField,
RangeField, RangeField,
) )
except ImportError: except ImportError:
ArrayField, HStoreField, JSONField, RangeField = (MissingType,) * 4 ArrayField, HStoreField, PGJSONField, RangeField = (MissingType,) * 4
try:
# JSONField is only available from Django 3.1
from django.db.models import JSONField
except ImportError:
JSONField = MissingType

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,5 +1,7 @@
from collections import OrderedDict
from django.db import models from django.db import models
from django.utils.encoding import force_text from django.utils.encoding import force_str
from django.utils.module_loading import import_string
from graphene import ( from graphene import (
ID, ID,
@ -18,18 +20,20 @@ from graphene import (
Time, Time,
) )
from graphene.types.json import JSONString from graphene.types.json import JSONString
from graphene.utils.str_converters import to_camel_case, to_const from graphene.utils.str_converters import to_camel_case
from graphql import assert_valid_name from graphql import assert_valid_name
from .compat import ArrayField, HStoreField, JSONField, RangeField from .settings import graphene_settings
from .compat import ArrayField, HStoreField, JSONField, PGJSONField, RangeField
from .fields import DjangoListField, DjangoConnectionField from .fields import DjangoListField, DjangoConnectionField
from .utils import import_single_dispatch from .utils import import_single_dispatch
from .utils.str_converters import to_const
singledispatch = import_single_dispatch() singledispatch = import_single_dispatch()
def convert_choice_name(name): def convert_choice_name(name):
name = to_const(force_text(name)) name = to_const(force_str(name))
try: try:
assert_valid_name(name) assert_valid_name(name)
except AssertionError: except AssertionError:
@ -39,6 +43,8 @@ def convert_choice_name(name):
def get_choices(choices): def get_choices(choices):
converted_names = [] converted_names = []
if isinstance(choices, OrderedDict):
choices = choices.items()
for value, help_text in choices: for value, help_text in choices:
if isinstance(help_text, (tuple, list)): if isinstance(help_text, (tuple, list)):
for choice in get_choices(help_text): for choice in get_choices(help_text):
@ -52,17 +58,7 @@ def get_choices(choices):
yield name, value, description yield name, value, description
def convert_django_field_with_choices( def convert_choices_to_named_enum_with_descriptions(name, choices):
field, registry=None, convert_choices_to_enum=True
):
if registry is not None:
converted = registry.get_converted_field(field)
if converted:
return converted
choices = getattr(field, "choices", None)
if choices and convert_choices_to_enum:
meta = field.model._meta
name = to_camel_case("{}_{}".format(meta.object_name, field.name))
choices = list(get_choices(choices)) choices = list(get_choices(choices))
named_choices = [(c[0], c[1]) for c in choices] named_choices = [(c[0], c[1]) for c in choices]
named_choices_descriptions = {c[0]: c[2] for c in choices} named_choices_descriptions = {c[0]: c[2] for c in choices}
@ -72,7 +68,44 @@ def convert_django_field_with_choices(
def description(self): def description(self):
return named_choices_descriptions[self.name] return named_choices_descriptions[self.name]
enum = Enum(name, list(named_choices), type=EnumWithDescriptionsType) 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
):
if registry is not None:
converted = registry.get_converted_field(field)
if converted:
return converted
choices = getattr(field, "choices", None)
if choices and convert_choices_to_enum:
enum = convert_choice_field_to_enum(field)
required = not (field.blank or field.null) required = not (field.blank or field.null)
converted = enum(description=field.help_text, required=required) converted = enum(description=field.help_text, required=required)
else: else:
@ -120,13 +153,9 @@ def convert_field_to_int(field, registry=None):
return Int(description=field.help_text, required=not field.null) return Int(description=field.help_text, required=not field.null)
@convert_django_field.register(models.NullBooleanField)
@convert_django_field.register(models.BooleanField) @convert_django_field.register(models.BooleanField)
def convert_field_to_boolean(field, registry=None): 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) return Boolean(description=field.help_text, required=not field.null)
@ -227,21 +256,30 @@ def convert_field_to_djangomodel(field, registry=None):
@convert_django_field.register(ArrayField) @convert_django_field.register(ArrayField)
def convert_postgres_array_to_list(field, registry=None): def convert_postgres_array_to_list(field, registry=None):
base_type = convert_django_field(field.base_field) inner_type = convert_django_field(field.base_field)
if not isinstance(base_type, (List, NonNull)): if not isinstance(inner_type, (List, NonNull)):
base_type = type(base_type) inner_type = (
return List(base_type, description=field.help_text, required=not field.null) NonNull(type(inner_type))
if inner_type.kwargs["required"]
else type(inner_type)
)
return List(inner_type, description=field.help_text, required=not field.null)
@convert_django_field.register(HStoreField) @convert_django_field.register(HStoreField)
@convert_django_field.register(PGJSONField)
@convert_django_field.register(JSONField) @convert_django_field.register(JSONField)
def convert_posgres_field_to_string(field, registry=None): def convert_pg_and_json_field_to_string(field, registry=None):
return JSONString(description=field.help_text, required=not field.null) return JSONString(description=field.help_text, required=not field.null)
@convert_django_field.register(RangeField) @convert_django_field.register(RangeField)
def convert_posgres_range_to_string(field, registry=None): def convert_postgres_range_to_string(field, registry=None):
inner_type = convert_django_field(field.base_field) inner_type = convert_django_field(field.base_field)
if not isinstance(inner_type, (List, NonNull)): if not isinstance(inner_type, (List, NonNull)):
inner_type = type(inner_type) inner_type = (
NonNull(type(inner_type))
if inner_type.kwargs["required"]
else type(inner_type)
)
return List(inner_type, description=field.help_text, required=not field.null) return List(inner_type, description=field.help_text, required=not field.null)

View File

@ -5,8 +5,8 @@ import json
from threading import local from threading import local
from time import time from time import time
from django.utils import six import six
from django.utils.encoding import force_text from django.utils.encoding import force_str
from .types import DjangoDebugSQL from .types import DjangoDebugSQL
@ -78,7 +78,7 @@ class NormalCursorWrapper(object):
def _quote_expr(self, element): def _quote_expr(self, element):
if isinstance(element, six.string_types): if isinstance(element, six.string_types):
return "'%s'" % force_text(element).replace("'", "''") return "'%s'" % force_str(element).replace("'", "''")
else: else:
return repr(element) return repr(element)
@ -91,7 +91,7 @@ class NormalCursorWrapper(object):
def _decode(self, param): def _decode(self, param):
try: try:
return force_text(param, strings_only=True) return force_str(param, strings_only=True)
except UnicodeDecodeError: except UnicodeDecodeError:
return "(encoded string)" return "(encoded string)"
@ -148,10 +148,10 @@ class NormalCursorWrapper(object):
# We keep `sql` to maintain backwards compatibility # We keep `sql` to maintain backwards compatibility
self.logger.object.sql.append(_sql) self.logger.object.sql.append(_sql)
def callproc(self, procname, params=()): def callproc(self, procname, params=None):
return self._record(self.cursor.callproc, procname, params) return self._record(self.cursor.callproc, procname, params)
def execute(self, sql, params=()): def execute(self, sql, params=None):
return self._record(self.cursor.execute, sql, params) return self._record(self.cursor.execute, sql, params)
def executemany(self, sql, param_list): def executemany(self, sql, param_list):

View File

@ -1,6 +1,5 @@
import pytest
import graphene import graphene
import pytest
from graphene.relay import Node from graphene.relay import Node
from graphene_django import DjangoConnectionField, DjangoObjectType from graphene_django import DjangoConnectionField, DjangoObjectType
@ -13,11 +12,6 @@ class context(object):
pass pass
# from examples.starwars_django.models import Character
pytestmark = pytest.mark.django_db
def test_should_query_field(): def test_should_query_field():
r1 = Reporter(last_name="ABA") r1 = Reporter(last_name="ABA")
r1.save() r1.save()
@ -31,7 +25,7 @@ def test_should_query_field():
class Query(graphene.ObjectType): class Query(graphene.ObjectType):
reporter = graphene.Field(ReporterType) reporter = graphene.Field(ReporterType)
debug = graphene.Field(DjangoDebug, name="_debug") debug = graphene.Field(DjangoDebug, name="__debug")
def resolve_reporter(self, info, **args): def resolve_reporter(self, info, **args):
return Reporter.objects.first() return Reporter.objects.first()
@ -41,7 +35,7 @@ def test_should_query_field():
reporter { reporter {
lastName lastName
} }
_debug { __debug {
sql { sql {
rawSql rawSql
} }
@ -50,7 +44,9 @@ def test_should_query_field():
""" """
expected = { expected = {
"reporter": {"lastName": "ABA"}, "reporter": {"lastName": "ABA"},
"_debug": {"sql": [{"rawSql": str(Reporter.objects.order_by("pk")[:1].query)}]}, "__debug": {
"sql": [{"rawSql": str(Reporter.objects.order_by("pk")[:1].query)}]
},
} }
schema = graphene.Schema(query=Query) schema = graphene.Schema(query=Query)
result = schema.execute( result = schema.execute(
@ -60,7 +56,10 @@ def test_should_query_field():
assert result.data == expected assert result.data == expected
def test_should_query_nested_field(): @pytest.mark.parametrize("max_limit", [None, 100])
def test_should_query_nested_field(graphene_settings, max_limit):
graphene_settings.RELAY_CONNECTION_MAX_LIMIT = max_limit
r1 = Reporter(last_name="ABA") r1 = Reporter(last_name="ABA")
r1.save() r1.save()
r2 = Reporter(last_name="Griffin") r2 = Reporter(last_name="Griffin")
@ -140,7 +139,7 @@ def test_should_query_list():
class Query(graphene.ObjectType): class Query(graphene.ObjectType):
all_reporters = graphene.List(ReporterType) all_reporters = graphene.List(ReporterType)
debug = graphene.Field(DjangoDebug, name="_debug") debug = graphene.Field(DjangoDebug, name="__debug")
def resolve_all_reporters(self, info, **args): def resolve_all_reporters(self, info, **args):
return Reporter.objects.all() return Reporter.objects.all()
@ -150,7 +149,7 @@ def test_should_query_list():
allReporters { allReporters {
lastName lastName
} }
_debug { __debug {
sql { sql {
rawSql rawSql
} }
@ -159,7 +158,7 @@ def test_should_query_list():
""" """
expected = { expected = {
"allReporters": [{"lastName": "ABA"}, {"lastName": "Griffin"}], "allReporters": [{"lastName": "ABA"}, {"lastName": "Griffin"}],
"_debug": {"sql": [{"rawSql": str(Reporter.objects.all().query)}]}, "__debug": {"sql": [{"rawSql": str(Reporter.objects.all().query)}]},
} }
schema = graphene.Schema(query=Query) schema = graphene.Schema(query=Query)
result = schema.execute( result = schema.execute(
@ -169,7 +168,10 @@ def test_should_query_list():
assert result.data == expected assert result.data == expected
def test_should_query_connection(): @pytest.mark.parametrize("max_limit", [None, 100])
def test_should_query_connection(graphene_settings, max_limit):
graphene_settings.RELAY_CONNECTION_MAX_LIMIT = max_limit
r1 = Reporter(last_name="ABA") r1 = Reporter(last_name="ABA")
r1.save() r1.save()
r2 = Reporter(last_name="Griffin") r2 = Reporter(last_name="Griffin")
@ -182,7 +184,7 @@ def test_should_query_connection():
class Query(graphene.ObjectType): class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType) all_reporters = DjangoConnectionField(ReporterType)
debug = graphene.Field(DjangoDebug, name="_debug") debug = graphene.Field(DjangoDebug, name="__debug")
def resolve_all_reporters(self, info, **args): def resolve_all_reporters(self, info, **args):
return Reporter.objects.all() return Reporter.objects.all()
@ -196,7 +198,7 @@ def test_should_query_connection():
} }
} }
} }
_debug { __debug {
sql { sql {
rawSql rawSql
} }
@ -210,12 +212,16 @@ def test_should_query_connection():
) )
assert not result.errors assert not result.errors
assert result.data["allReporters"] == expected["allReporters"] assert result.data["allReporters"] == expected["allReporters"]
assert "COUNT" in result.data["_debug"]["sql"][0]["rawSql"] assert len(result.data["__debug"]["sql"]) == 2
assert "COUNT" in result.data["__debug"]["sql"][0]["rawSql"]
query = str(Reporter.objects.all()[:1].query) query = str(Reporter.objects.all()[:1].query)
assert result.data["_debug"]["sql"][1]["rawSql"] == query assert result.data["__debug"]["sql"][1]["rawSql"] == query
def test_should_query_connectionfilter(): @pytest.mark.parametrize("max_limit", [None, 100])
def test_should_query_connectionfilter(graphene_settings, max_limit):
graphene_settings.RELAY_CONNECTION_MAX_LIMIT = max_limit
from ...filter import DjangoFilterConnectionField from ...filter import DjangoFilterConnectionField
r1 = Reporter(last_name="ABA") r1 = Reporter(last_name="ABA")
@ -231,7 +237,7 @@ def test_should_query_connectionfilter():
class Query(graphene.ObjectType): class Query(graphene.ObjectType):
all_reporters = DjangoFilterConnectionField(ReporterType, fields=["last_name"]) all_reporters = DjangoFilterConnectionField(ReporterType, fields=["last_name"])
s = graphene.String(resolver=lambda *_: "S") s = graphene.String(resolver=lambda *_: "S")
debug = graphene.Field(DjangoDebug, name="_debug") debug = graphene.Field(DjangoDebug, name="__debug")
def resolve_all_reporters(self, info, **args): def resolve_all_reporters(self, info, **args):
return Reporter.objects.all() return Reporter.objects.all()
@ -245,7 +251,7 @@ def test_should_query_connectionfilter():
} }
} }
} }
_debug { __debug {
sql { sql {
rawSql rawSql
} }
@ -259,6 +265,7 @@ def test_should_query_connectionfilter():
) )
assert not result.errors assert not result.errors
assert result.data["allReporters"] == expected["allReporters"] assert result.data["allReporters"] == expected["allReporters"]
assert "COUNT" in result.data["_debug"]["sql"][0]["rawSql"] assert len(result.data["__debug"]["sql"]) == 2
assert "COUNT" in result.data["__debug"]["sql"][0]["rawSql"]
query = str(Reporter.objects.all()[:1].query) query = str(Reporter.objects.all()[:1].query)
assert result.data["_debug"]["sql"][1]["rawSql"] == query assert result.data["__debug"]["sql"][1]["rawSql"] == query

View File

@ -1,13 +1,16 @@
from functools import partial from functools import partial
import six
from django.db.models.query import QuerySet from django.db.models.query import QuerySet
from graphene import NonNull from graphql_relay.connection.arrayconnection import (
connection_from_list_slice,
get_offset_with_default,
)
from promise import Promise from promise import Promise
from graphene.types import Field, List from graphene import NonNull
from graphene.relay import ConnectionField, PageInfo from graphene.relay import ConnectionField, PageInfo
from graphql_relay.connection.arrayconnection import connection_from_list_slice from graphene.types import Field, List
from .settings import graphene_settings from .settings import graphene_settings
from .utils import maybe_queryset from .utils import maybe_queryset
@ -15,19 +18,57 @@ from .utils import maybe_queryset
class DjangoListField(Field): class DjangoListField(Field):
def __init__(self, _type, *args, **kwargs): def __init__(self, _type, *args, **kwargs):
from .types import DjangoObjectType
if isinstance(_type, NonNull):
_type = _type.of_type
# Django would never return a Set of None vvvvvvv # Django would never return a Set of None vvvvvvv
super(DjangoListField, self).__init__(List(NonNull(_type)), *args, **kwargs) 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 @property
def model(self): def model(self):
return self.type.of_type._meta.node._meta.model return self._underlying_type._meta.model
def get_default_queryset(self):
return self.model._default_manager.get_queryset()
@staticmethod @staticmethod
def list_resolver(resolver, root, info, **args): def list_resolver(
return maybe_queryset(resolver(root, info, **args)) django_object_type, resolver, default_queryset, root, info, **args
):
queryset = maybe_queryset(resolver(root, info, **args))
if queryset is None:
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): def get_resolver(self, parent_resolver):
return partial(self.list_resolver, parent_resolver) _type = self.type
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,
self.get_default_queryset(),
)
class DjangoConnectionField(ConnectionField): class DjangoConnectionField(ConnectionField):
@ -85,40 +126,44 @@ class DjangoConnectionField(ConnectionField):
@classmethod @classmethod
def resolve_queryset(cls, connection, queryset, info, args): def resolve_queryset(cls, connection, queryset, info, args):
# queryset is the resolved iterable from ObjectType
return connection._meta.node.get_queryset(queryset, info) return connection._meta.node.get_queryset(queryset, info)
@classmethod @classmethod
def merge_querysets(cls, default_queryset, queryset): def resolve_connection(cls, connection, args, iterable, max_limit=None):
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
iterable = maybe_queryset(iterable) iterable = maybe_queryset(iterable)
if isinstance(iterable, QuerySet): if isinstance(iterable, QuerySet):
if iterable.model.objects is not default_manager: list_length = iterable.count()
default_queryset = maybe_queryset(default_manager) list_slice_length = (
iterable = cls.merge_querysets(default_queryset, iterable) min(max_limit, list_length) if max_limit is not None else list_length
_len = iterable.count() )
else: else:
_len = len(iterable) list_length = len(iterable)
list_slice_length = (
min(max_limit, list_length) if max_limit is not None else list_length
)
# If after is higher than list_length, connection_from_list_slice
# would try to do a negative slicing which makes django throw an
# AssertionError
after = min(get_offset_with_default(args.get("after"), -1) + 1, list_length)
if max_limit is not None and "first" not in args:
args["first"] = max_limit
connection = connection_from_list_slice( connection = connection_from_list_slice(
iterable, iterable[after:],
args, args,
slice_start=0, slice_start=after,
list_length=_len, list_length=list_length,
list_slice_length=_len, list_slice_length=list_slice_length,
connection_type=connection, connection_type=connection,
edge_type=connection.Edge, edge_type=connection.Edge,
pageinfo_type=PageInfo, pageinfo_type=PageInfo,
) )
connection.iterable = iterable connection.iterable = iterable
connection.length = _len connection.length = list_length
return connection return connection
@classmethod @classmethod
@ -127,19 +172,20 @@ class DjangoConnectionField(ConnectionField):
resolver, resolver,
connection, connection,
default_manager, default_manager,
queryset_resolver,
max_limit, max_limit,
enforce_first_or_last, enforce_first_or_last,
root, root,
info, info,
**kwargs **args
): ):
# By current `connection_from_list_slice` implementation, # By current `connection_from_list_slice` implementation,
# `last` means last N items in the selection, # `last` means last N items in the selection,
# and when use `last` with `first`, `last` means last N items in first N items. # and when use `last` with `first`, `last` means last N items in first N items.
first = kwargs.get("first") first = args.get("first")
last = kwargs.get("last") last = args.get("last")
if first is not None and first <= 0: if first is not None and first <= 0:
raise ValueError( raise ValueError(
"`first` argument must be positive, got `{first}`".format(first=first) "`first` argument must be positive, got `{first}`".format(first=first)
@ -159,7 +205,7 @@ class DjangoConnectionField(ConnectionField):
if not max_limit: if not max_limit:
pass pass
elif first is None and last is None: elif first is None and last is None:
kwargs["first"] = max_limit args["first"] = max_limit
else: else:
count = min(i for i in (first, last) if i) count = min(i for i in (first, last) if i)
if count > max_limit: if count > max_limit:
@ -171,9 +217,17 @@ class DjangoConnectionField(ConnectionField):
).format(count=count, info=info, max_limit=max_limit) ).format(count=count, info=info, max_limit=max_limit)
) )
iterable = resolver(root, info, **kwargs) # eventually leads to DjangoObjectType's get_queryset (accepts queryset)
queryset = cls.resolve_queryset(connection, default_manager, info, kwargs) # or a resolve_foo (does not accept queryset)
on_resolve = partial(cls.resolve_connection, connection, queryset, kwargs) iterable = resolver(root, info, **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, max_limit=max_limit
)
if Promise.is_thenable(iterable): if Promise.is_thenable(iterable):
return Promise.resolve(iterable).then(on_resolve) return Promise.resolve(iterable).then(on_resolve)
@ -186,6 +240,10 @@ class DjangoConnectionField(ConnectionField):
parent_resolver, parent_resolver,
self.connection_type, self.connection_type,
self.get_manager(), self.get_manager(),
self.get_queryset_resolver(),
self.max_limit, self.max_limit,
self.enforce_first_or_last, self.enforce_first_or_last,
) )
def get_queryset_resolver(self):
return self.resolve_queryset

View File

@ -1,6 +1,7 @@
from collections import OrderedDict from collections import OrderedDict
from functools import partial from functools import partial
from django.core.exceptions import ValidationError
from graphene.types.argument import to_arguments from graphene.types.argument import to_arguments
from ..fields import DjangoConnectionField from ..fields import DjangoConnectionField
from .utils import get_filtering_args_from_filterset, get_filterset_class from .utils import get_filtering_args_from_filterset, get_filterset_class
@ -52,69 +53,23 @@ class DjangoFilterConnectionField(DjangoConnectionField):
return get_filtering_args_from_filterset(self.filterset_class, self.node_type) return get_filtering_args_from_filterset(self.filterset_class, self.node_type)
@classmethod @classmethod
def merge_querysets(cls, default_queryset, queryset): def resolve_queryset(
# There could be the case where the default queryset (returned from the filterclass) cls, connection, iterable, info, args, filtering_args, filterset_class
# 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
): ):
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} filter_kwargs = {k: v for k, v in args.items() if k in filtering_args}
qs = filterset_class( filterset = filterset_class(
data=filter_kwargs, data=filter_kwargs, queryset=qs, request=info.context
queryset=default_manager.get_queryset(),
request=info.context,
).qs
return super(DjangoFilterConnectionField, cls).connection_resolver(
resolver,
connection,
qs,
max_limit,
enforce_first_or_last,
root,
info,
**args
) )
if filterset.form.is_valid():
return filterset.qs
raise ValidationError(filterset.form.errors.as_json())
def get_resolver(self, parent_resolver): def get_queryset_resolver(self):
return partial( return partial(
self.connection_resolver, self.resolve_queryset,
parent_resolver, filterset_class=self.filterset_class,
self.connection_type, filtering_args=self.filtering_args,
self.get_manager(),
self.max_limit,
self.enforce_first_or_last,
self.filterset_class,
self.filtering_args,
) )

View File

@ -35,9 +35,6 @@ else:
) )
) )
pytestmark.append(pytest.mark.django_db)
if DJANGO_FILTER_INSTALLED: if DJANGO_FILTER_INSTALLED:
class ArticleNode(DjangoObjectType): class ArticleNode(DjangoObjectType):
@ -56,8 +53,6 @@ if DJANGO_FILTER_INSTALLED:
model = Pet model = Pet
interfaces = (Node,) interfaces = (Node,)
# schema = Schema()
def get_args(field): def get_args(field):
return field.args return field.args
@ -182,7 +177,7 @@ def test_filter_shortcut_filterset_context():
} }
""" """
schema = Schema(query=Query) schema = Schema(query=Query)
result = schema.execute(query, context=context()) result = schema.execute(query, context_value=context())
assert not result.errors assert not result.errors
assert len(result.data["contextArticles"]["edges"]) == 1 assert len(result.data["contextArticles"]["edges"]) == 1
@ -405,6 +400,114 @@ def test_global_id_field_relation():
assert id_filter.field_class == GlobalIDFormField assert id_filter.field_class == GlobalIDFormField
def test_global_id_field_relation_with_filter():
class ReporterFilterNode(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
filter_fields = ["first_name", "articles"]
class ArticleFilterNode(DjangoObjectType):
class Meta:
model = Article
interfaces = (Node,)
filter_fields = ["headline", "reporter"]
class Query(ObjectType):
all_reporters = DjangoFilterConnectionField(ReporterFilterNode)
all_articles = DjangoFilterConnectionField(ArticleFilterNode)
reporter = Field(ReporterFilterNode)
article = Field(ArticleFilterNode)
r1 = Reporter.objects.create(first_name="r1", last_name="r1", email="r1@test.com")
r2 = Reporter.objects.create(first_name="r2", last_name="r2", email="r2@test.com")
Article.objects.create(
headline="a1",
pub_date=datetime.now(),
pub_date_time=datetime.now(),
reporter=r1,
editor=r1,
)
Article.objects.create(
headline="a2",
pub_date=datetime.now(),
pub_date_time=datetime.now(),
reporter=r2,
editor=r2,
)
# Query articles created by the reporter `r1`
query = """
query {
allArticles (reporter: "UmVwb3J0ZXJGaWx0ZXJOb2RlOjE=") {
edges {
node {
id
}
}
}
}
"""
schema = Schema(query=Query)
result = schema.execute(query)
assert not result.errors
# We should only get back a single article
assert len(result.data["allArticles"]["edges"]) == 1
def test_global_id_field_relation_with_filter_not_valid_id():
class ReporterFilterNode(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
filter_fields = ["first_name", "articles"]
class ArticleFilterNode(DjangoObjectType):
class Meta:
model = Article
interfaces = (Node,)
filter_fields = ["headline", "reporter"]
class Query(ObjectType):
all_reporters = DjangoFilterConnectionField(ReporterFilterNode)
all_articles = DjangoFilterConnectionField(ArticleFilterNode)
reporter = Field(ReporterFilterNode)
article = Field(ArticleFilterNode)
r1 = Reporter.objects.create(first_name="r1", last_name="r1", email="r1@test.com")
r2 = Reporter.objects.create(first_name="r2", last_name="r2", email="r2@test.com")
Article.objects.create(
headline="a1",
pub_date=datetime.now(),
pub_date_time=datetime.now(),
reporter=r1,
editor=r1,
)
Article.objects.create(
headline="a2",
pub_date=datetime.now(),
pub_date_time=datetime.now(),
reporter=r2,
editor=r2,
)
# Filter by the global ID that does not exist
query = """
query {
allArticles (reporter: "fake_global_id") {
edges {
node {
id
}
}
}
}
"""
schema = Schema(query=Query)
result = schema.execute(query)
assert "Invalid ID specified." in result.errors[0].message
def test_global_id_multiple_field_implicit(): def test_global_id_multiple_field_implicit():
field = DjangoFilterConnectionField(ReporterNode, fields=["pets"]) field = DjangoFilterConnectionField(ReporterNode, fields=["pets"])
filterset_class = field.filterset_class filterset_class = field.filterset_class
@ -610,58 +713,6 @@ def test_should_query_filter_node_limit():
assert result.data == expected 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(): def test_order_by_is_perserved():
class ReporterType(DjangoObjectType): class ReporterType(DjangoObjectType):
class Meta: class Meta:
@ -723,7 +774,7 @@ def test_order_by_is_perserved():
assert reverse_result.data == reverse_expected assert reverse_result.data == reverse_expected
def test_annotation_is_perserved(): def test_annotation_is_preserved():
class ReporterType(DjangoObjectType): class ReporterType(DjangoObjectType):
full_name = String() full_name = String()
@ -768,6 +819,86 @@ def test_annotation_is_perserved():
assert result.data == expected 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(): def test_integer_field_filter_type():
class PetType(DjangoObjectType): class PetType(DjangoObjectType):
class Meta: class Meta:
@ -820,6 +951,58 @@ def test_integer_field_filter_type():
) )
def test_other_filter_types():
class PetType(DjangoObjectType):
class Meta:
model = Pet
interfaces = (Node,)
filter_fields = {"age": ["exact", "isnull", "lt"]}
fields = ("age",)
class Query(ObjectType):
pets = DjangoFilterConnectionField(PetType)
schema = Schema(query=Query)
assert str(schema) == dedent(
"""\
schema {
query: Query
}
interface Node {
id: ID!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!
startCursor: String
endCursor: String
}
type PetType implements Node {
age: Int!
id: ID!
}
type PetTypeConnection {
pageInfo: PageInfo!
edges: [PetTypeEdge]!
}
type PetTypeEdge {
node: PetType
cursor: String!
}
type Query {
pets(before: String, after: String, first: Int, last: Int, age: Int, age_Isnull: Boolean, age_Lt: Int): PetTypeConnection
}
"""
)
def test_filter_filterset_based_on_mixin(): def test_filter_filterset_based_on_mixin():
class ArticleFilterMixin(FilterSet): class ArticleFilterMixin(FilterSet):
@classmethod @classmethod

View File

@ -1,5 +1,6 @@
import six import six
from django_filters.utils import get_model_field
from .filterset import custom_filterset_factory, setup_filterset from .filterset import custom_filterset_factory, setup_filterset
@ -18,12 +19,9 @@ def get_filtering_args_from_filterset(filterset_class, type):
if name in filterset_class.declared_filters: if name in filterset_class.declared_filters:
form_field = filter_field.field form_field = filter_field.field
else: else:
field_name = name.split("__", 1)[0] model_field = get_model_field(model, filter_field.field_name)
filter_type = filter_field.lookup_expr
if hasattr(model, field_name): if filter_type != "isnull" and hasattr(model_field, "formfield"):
model_field = model._meta.get_field(field_name)
if hasattr(model_field, "formfield"):
form_field = model_field.formfield( form_field = model_field.formfield(
required=filter_field.extra.get("required", False) required=filter_field.extra.get("required", False)
) )

View File

@ -2,7 +2,7 @@ import binascii
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.forms import CharField, Field, MultipleChoiceField 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 from graphql_relay import from_global_id

View File

@ -47,7 +47,7 @@ class BaseDjangoFormMutation(ClientIDMutation):
else: else:
errors = ErrorType.from_errors(form.errors) errors = ErrorType.from_errors(form.errors)
return cls(errors=errors) return cls(errors=errors, **form.data)
@classmethod @classmethod
def get_form(cls, root, info, **input): def get_form(cls, root, info, **input):
@ -66,28 +66,6 @@ class BaseDjangoFormMutation(ClientIDMutation):
return kwargs 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): class DjangoFormMutationOptions(MutationOptions):
form_class = None form_class = None
@ -122,7 +100,7 @@ class DjangoFormMutation(BaseDjangoFormMutation):
@classmethod @classmethod
def perform_mutate(cls, form, info): def perform_mutate(cls, form, info):
form.save() form.save()
return cls(errors=[]) return cls(errors=[], **form.cleaned_data)
class DjangoModelDjangoFormMutationOptions(DjangoFormMutationOptions): class DjangoModelDjangoFormMutationOptions(DjangoFormMutationOptions):
@ -163,7 +141,9 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
registry = get_global_registry() registry = get_global_registry()
model_type = registry.get_type_for_model(model) 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: if not return_field_name:
model_name = model.__name__ model_name = model.__name__
return_field_name = model_name[:1].lower() + model_name[1:] return_field_name = model_name[:1].lower() + model_name[1:]
@ -182,6 +162,17 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
_meta=_meta, input_fields=input_fields, **options _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 @classmethod
def perform_mutate(cls, form, info): def perform_mutate(cls, form, info):
obj = form.save() obj = form.save()

View File

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

View File

@ -1,3 +1,4 @@
import os
import importlib import importlib
import json import json
import functools import functools
@ -5,6 +6,7 @@ import functools
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.utils import autoreload from django.utils import autoreload
from graphql import print_schema
from graphene_django.settings import graphene_settings from graphene_django.settings import graphene_settings
@ -44,24 +46,41 @@ class CommandArguments(BaseCommand):
class Command(CommandArguments): class Command(CommandArguments):
help = "Dump Graphene schema JSON to file" help = "Dump Graphene schema as a JSON or GraphQL file"
can_import_settings = True 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: with open(out, "w") as outfile:
json.dump(schema_dict, outfile, indent=indent, sort_keys=True) json.dump(schema_dict, outfile, indent=indent, sort_keys=True)
def save_graphql_file(self, out, schema):
with open(out, "w") as outfile:
outfile.write(print_schema(schema))
def get_schema(self, schema, out, indent): def get_schema(self, schema, out, indent):
schema_dict = {"data": schema.introspect()} schema_dict = {"data": schema.introspect()}
if out == "-": if out == "-":
self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True)) self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True))
else: 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) style = getattr(self, "style", None)
success = getattr(style, "SUCCESS", lambda x: x) 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): def handle(self, *args, **options):
options_schema = options.get("schema") options_schema = options.get("schema")

View File

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

View File

@ -4,6 +4,7 @@ from rest_framework import serializers
import graphene import graphene
from ..registry import get_global_registry from ..registry import get_global_registry
from ..converter import convert_choices_to_named_enum_with_descriptions
from ..utils import import_single_dispatch from ..utils import import_single_dispatch
from .types import DictType from .types import DictType
@ -18,13 +19,16 @@ 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 Converts a django rest frameworks field to a graphql field
and marks the field as required if we are creating an input type and marks the field as required if we are creating an input type
and the field itself is required and the field itself is required
""" """
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) graphql_type = get_graphene_type_from_serializer_field(field)
args = [] args = []
@ -130,7 +134,6 @@ def convert_serializer_field_to_time(field):
@get_graphene_type_from_serializer_field.register(serializers.ListField) @get_graphene_type_from_serializer_field.register(serializers.ListField)
def convert_serializer_field_to_list(field, is_input=True): def convert_serializer_field_to_list(field, is_input=True):
child_type = get_graphene_type_from_serializer_field(field.child) child_type = get_graphene_type_from_serializer_field(field.child)
return (graphene.List, child_type) return (graphene.List, child_type)
@ -145,5 +148,13 @@ def convert_serializer_field_to_jsonstring(field):
@get_graphene_type_from_serializer_field.register(serializers.MultipleChoiceField) @get_graphene_type_from_serializer_field.register(serializers.MultipleChoiceField)
def convert_serializer_field_to_list_of_string(field): def convert_serializer_field_to_list_of_enum(field):
return (graphene.List, graphene.String) child_type = convert_serializer_field_to_enum(field)
return (graphene.List, child_type)
@get_graphene_type_from_serializer_field.register(serializers.ChoiceField)
def convert_serializer_field_to_enum(field):
# enums require a name
name = field.field_name or field.source or "Choices"
return convert_choices_to_named_enum_with_descriptions(name, field.choices)

View File

@ -10,7 +10,9 @@ from ..serializer_converter import convert_serializer_field
from ..types import DictType 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: # prevents the following error:
# AssertionError: The `source` argument is not meaningful when applied to a `child=` field. # AssertionError: The `source` argument is not meaningful when applied to a `child=` field.
# Remove `source=` from the field declaration. # 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) 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): def assert_conversion(rest_framework_field, graphene_field, **kwargs):
@ -60,8 +64,27 @@ def test_should_url_convert_string():
assert_conversion(serializers.URLField, graphene.String) assert_conversion(serializers.URLField, graphene.String)
def test_should_choice_convert_string(): def test_should_choice_convert_enum():
assert_conversion(serializers.ChoiceField, graphene.String, choices=[]) field = assert_conversion(
serializers.ChoiceField,
graphene.Enum,
choices=[("h", "Hello"), ("w", "World")],
source="word",
)
assert field._meta.enum.__members__["H"].value == "h"
assert field._meta.enum.__members__["H"].description == "Hello"
assert field._meta.enum.__members__["W"].value == "w"
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(): def test_should_base_field_convert_string():
@ -174,7 +197,7 @@ def test_should_file_convert_string():
def test_should_filepath_convert_string(): def test_should_filepath_convert_string():
assert_conversion(serializers.FilePathField, graphene.String, path="/") assert_conversion(serializers.FilePathField, graphene.Enum, path="/")
def test_should_ip_convert_string(): def test_should_ip_convert_string():
@ -189,9 +212,9 @@ def test_should_json_convert_jsonstring():
assert_conversion(serializers.JSONField, graphene.types.json.JSONString) assert_conversion(serializers.JSONField, graphene.types.json.JSONString)
def test_should_multiplechoicefield_convert_to_list_of_string(): def test_should_multiplechoicefield_convert_to_list_of_enum():
field = assert_conversion( field = assert_conversion(
serializers.MultipleChoiceField, graphene.List, choices=[1, 2, 3] serializers.MultipleChoiceField, graphene.List, choices=[1, 2, 3]
) )
assert field.of_type == graphene.String assert issubclass(field.of_type, graphene.Enum)

View File

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

View File

@ -1,14 +1,13 @@
import datetime import datetime
from py.test import mark, raises from py.test import raises
from rest_framework import serializers from rest_framework import serializers
from graphene import Field, ResolveInfo from graphene import Field, ResolveInfo
from graphene.types.inputobjecttype import InputObjectType from graphene.types.inputobjecttype import InputObjectType
from ...settings import graphene_settings
from ...types import DjangoObjectType from ...types import DjangoObjectType
from ..models import MyFakeModel, MyFakeModelWithPassword from ..models import MyFakeModel, MyFakeModelWithDate, MyFakeModelWithPassword
from ..mutation import SerializerMutation from ..mutation import SerializerMutation
@ -33,6 +32,18 @@ class MyModelSerializer(serializers.ModelSerializer):
fields = "__all__" 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 MyModelMutation(SerializerMutation):
class Meta: class Meta:
serializer_class = MyModelSerializer serializer_class = MyModelSerializer
@ -87,7 +98,6 @@ def test_exclude_fields():
assert "created" not in MyMutation.Input._meta.fields assert "created" not in MyMutation.Input._meta.fields
@mark.django_db
def test_write_only_field(): def test_write_only_field():
class WriteOnlyFieldModelSerializer(serializers.ModelSerializer): class WriteOnlyFieldModelSerializer(serializers.ModelSerializer):
password = serializers.CharField(write_only=True) password = serializers.CharField(write_only=True)
@ -110,7 +120,6 @@ def test_write_only_field():
), "'password' is write_only field and shouldn't be visible" ), "'password' is write_only field and shouldn't be visible"
@mark.django_db
def test_write_only_field_using_extra_kwargs(): def test_write_only_field_using_extra_kwargs():
class WriteOnlyFieldModelSerializer(serializers.ModelSerializer): class WriteOnlyFieldModelSerializer(serializers.ModelSerializer):
class Meta: class Meta:
@ -132,6 +141,24 @@ def test_write_only_field_using_extra_kwargs():
), "'password' is write_only field and shouldn't be visible" ), "'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(): def test_nested_model():
class MyFakeModelGrapheneType(DjangoObjectType): class MyFakeModelGrapheneType(DjangoObjectType):
class Meta: class Meta:
@ -163,7 +190,6 @@ def test_mutate_and_get_payload_success():
assert result.errors is None assert result.errors is None
@mark.django_db
def test_model_add_mutate_and_get_payload_success(): def test_model_add_mutate_and_get_payload_success():
result = MyModelMutation.mutate_and_get_payload( result = MyModelMutation.mutate_and_get_payload(
None, mock_info(), **{"cool_name": "Narf"} None, mock_info(), **{"cool_name": "Narf"}
@ -173,7 +199,6 @@ def test_model_add_mutate_and_get_payload_success():
assert isinstance(result.created, datetime.datetime) assert isinstance(result.created, datetime.datetime)
@mark.django_db
def test_model_update_mutate_and_get_payload_success(): def test_model_update_mutate_and_get_payload_success():
instance = MyFakeModel.objects.create(cool_name="Narf") instance = MyFakeModel.objects.create(cool_name="Narf")
result = MyModelMutation.mutate_and_get_payload( result = MyModelMutation.mutate_and_get_payload(
@ -183,7 +208,15 @@ def test_model_update_mutate_and_get_payload_success():
assert result.cool_name == "New Narf" 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(): def test_model_invalid_update_mutate_and_get_payload_success():
class InvalidModelMutation(SerializerMutation): class InvalidModelMutation(SerializerMutation):
class Meta: class Meta:
@ -198,6 +231,22 @@ def test_model_invalid_update_mutate_and_get_payload_success():
assert '"id" required' in str(exc.value) 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(): def test_mutate_and_get_payload_error():
class MyMutation(SerializerMutation): class MyMutation(SerializerMutation):
class Meta: class Meta:
@ -214,11 +263,10 @@ def test_model_mutate_and_get_payload_error():
assert len(result.errors) > 0 assert len(result.errors) > 0
def test_mutation_error_camelcased(): def test_mutation_error_camelcased(graphene_settings):
graphene_settings.CAMELCASE_ERRORS = True graphene_settings.CAMELCASE_ERRORS = True
result = MyModelMutation.mutate_and_get_payload(None, mock_info(), **{}) result = MyModelMutation.mutate_and_get_payload(None, mock_info(), **{})
assert result.errors[0].field == "coolName" assert result.errors[0].field == "coolName"
graphene_settings.CAMELCASE_ERRORS = False
def test_invalid_serializer_operations(): def test_invalid_serializer_operations():

View File

@ -13,9 +13,9 @@ back to the defaults.
""" """
from __future__ import unicode_literals from __future__ import unicode_literals
import six
from django.conf import settings from django.conf import settings
from django.test.signals import setting_changed from django.test.signals import setting_changed
from django.utils import six
try: try:
import importlib # Available in Python 3.1+ import importlib # Available in Python 3.1+
@ -36,6 +36,15 @@ DEFAULTS = {
# Max items returned in ConnectionFields / FilterConnectionFields # Max items returned in ConnectionFields / FilterConnectionFields
"RELAY_CONNECTION_MAX_LIMIT": 100, "RELAY_CONNECTION_MAX_LIMIT": 100,
"CAMELCASE_ERRORS": False, "CAMELCASE_ERRORS": False,
# Set to True to enable v3 naming convention for choice field Enum's
"DJANGO_CHOICE_FIELD_ENUM_V3_NAMING": False,
"DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME": None,
# Use a separate path for handling subscriptions.
"SUBSCRIPTION_PATH": None,
# By default GraphiQL headers editor tab is enabled, set to False to hide it
# This sets headerEditorEnabled GraphiQL option, for details go to
# https://github.com/graphql/graphiql/tree/main/packages/graphiql#options
"GRAPHIQL_HEADER_EDITOR_ENABLED": True,
} }
if settings.DEBUG: if settings.DEBUG:

View File

@ -1,32 +1,55 @@
(function() { (function (
document,
GRAPHENE_SETTINGS,
GraphiQL,
React,
ReactDOM,
SubscriptionsTransportWs,
fetch,
history,
location,
) {
// Parse the cookie value for a CSRF token // Parse the cookie value for a CSRF token
var csrftoken; var csrftoken;
var cookies = ('; ' + document.cookie).split('; csrftoken='); var cookies = ("; " + document.cookie).split("; csrftoken=");
if (cookies.length == 2) if (cookies.length == 2) {
csrftoken = cookies.pop().split(';').shift(); csrftoken = cookies.pop().split(";").shift();
} else {
csrftoken = document.querySelector("[name=csrfmiddlewaretoken]").value;
}
// Collect the URL parameters // Collect the URL parameters
var parameters = {}; var parameters = {};
window.location.hash.substr(1).split('&').forEach(function (entry) { location.hash
var eq = entry.indexOf('='); .substr(1)
.split("&")
.forEach(function (entry) {
var eq = entry.indexOf("=");
if (eq >= 0) { if (eq >= 0) {
parameters[decodeURIComponent(entry.slice(0, eq))] = parameters[decodeURIComponent(entry.slice(0, eq))] = decodeURIComponent(
decodeURIComponent(entry.slice(eq + 1)); entry.slice(eq + 1),
);
} }
}); });
// Produce a Location fragment string from a parameter object. // Produce a Location fragment string from a parameter object.
function locationQuery(params) { function locationQuery(params) {
return '#' + Object.keys(params).map(function (key) { return (
return encodeURIComponent(key) + '=' + "#" +
encodeURIComponent(params[key]); Object.keys(params)
}).join('&'); .map(function (key) {
return (
encodeURIComponent(key) + "=" + encodeURIComponent(params[key])
);
})
.join("&")
);
} }
// Derive a fetch URL from the current URL, sans the GraphQL parameters. // Derive a fetch URL from the current URL, sans the GraphQL parameters.
var graphqlParamNames = { var graphqlParamNames = {
query: true, query: true,
variables: true, variables: true,
operationName: true operationName: true,
}; };
var otherParams = {}; var otherParams = {};
for (var k in parameters) { for (var k in parameters) {
@ -38,22 +61,26 @@
var fetchURL = locationQuery(otherParams); var fetchURL = locationQuery(otherParams);
// Defines a GraphQL fetcher using the fetch API. // Defines a GraphQL fetcher using the fetch API.
function graphQLFetcher(graphQLParams) { function httpClient(graphQLParams, opts) {
var headers = { if (typeof opts === 'undefined') {
'Accept': 'application/json', opts = {};
'Content-Type': 'application/json' }
}; var headers = opts.headers || {};
headers['Accept'] = headers['Accept'] || 'application/json';
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
if (csrftoken) { if (csrftoken) {
headers['X-CSRFToken'] = csrftoken; headers['X-CSRFToken'] = csrftoken
} }
return fetch(fetchURL, { return fetch(fetchURL, {
method: 'post', method: "post",
headers: headers, headers: headers,
body: JSON.stringify(graphQLParams), body: JSON.stringify(graphQLParams),
credentials: 'include', credentials: "include",
}).then(function (response) { })
.then(function (response) {
return response.text(); return response.text();
}).then(function (responseBody) { })
.then(function (responseBody) {
try { try {
return JSON.parse(responseBody); return JSON.parse(responseBody);
} catch (error) { } catch (error) {
@ -61,6 +88,71 @@
} }
}); });
} }
// Derive the subscription URL. If the SUBSCRIPTION_URL setting is specified, uses that value. Otherwise
// assumes the current window location with an appropriate websocket protocol.
var subscribeURL =
location.origin.replace(/^http/, "ws") +
(GRAPHENE_SETTINGS.subscriptionPath || location.pathname);
// Create a subscription client.
var subscriptionClient = new SubscriptionsTransportWs.SubscriptionClient(
subscribeURL,
{
// Reconnect after any interruptions.
reconnect: true,
// Delay socket initialization until the first subscription is started.
lazy: true,
},
);
// Keep a reference to the currently-active subscription, if available.
var activeSubscription = null;
// Define a GraphQL fetcher that can intelligently route queries based on the operation type.
function graphQLFetcher(graphQLParams, opts) {
var operationType = getOperationType(graphQLParams);
// If we're about to execute a new operation, and we have an active subscription,
// unsubscribe before continuing.
if (activeSubscription) {
activeSubscription.unsubscribe();
activeSubscription = null;
}
if (operationType === "subscription") {
return {
subscribe: function (observer) {
subscriptionClient.request(graphQLParams).subscribe(observer);
activeSubscription = subscriptionClient;
},
};
} else {
return httpClient(graphQLParams, opts);
}
}
// Determine the type of operation being executed for a given set of GraphQL parameters.
function getOperationType(graphQLParams) {
// Run a regex against the query to determine the operation type (query, mutation, subscription).
var operationRegex = new RegExp(
// Look for lines that start with an operation keyword, ignoring whitespace.
"^\\s*(query|mutation|subscription)\\s*" +
// The operation keyword should be followed by whitespace and the operationName in the GraphQL parameters (if available).
(graphQLParams.operationName ? ("\\s+" + graphQLParams.operationName) : "") +
// The line should eventually encounter an opening curly brace.
"[^\\{]*\\{",
// Enable multiline matching.
"m",
);
var match = operationRegex.exec(graphQLParams.query);
if (!match) {
return "query";
}
return match[1];
}
// When the query and variables string is edited, update the URL bar so // When the query and variables string is edited, update the URL bar so
// that it can be easily shared. // that it can be easily shared.
function onEditQuery(newQuery) { function onEditQuery(newQuery) {
@ -83,8 +175,9 @@
onEditQuery: onEditQuery, onEditQuery: onEditQuery,
onEditVariables: onEditVariables, onEditVariables: onEditVariables,
onEditOperationName: onEditOperationName, onEditOperationName: onEditOperationName,
headerEditorEnabled: GRAPHENE_SETTINGS.graphiqlHeaderEditorEnabled,
query: parameters.query, query: parameters.query,
} };
if (parameters.variables) { if (parameters.variables) {
options.variables = parameters.variables; options.variables = parameters.variables;
} }
@ -94,6 +187,17 @@
// Render <GraphiQL /> into the body. // Render <GraphiQL /> into the body.
ReactDOM.render( ReactDOM.render(
React.createElement(GraphiQL, options), React.createElement(GraphiQL, options),
document.body document.getElementById("editor"),
);
})(
document,
window.GRAPHENE_SETTINGS,
window.GraphiQL,
window.React,
window.ReactDOM,
window.SubscriptionsTransportWs,
window.fetch,
window.history,
window.location,
); );
})();

View File

@ -10,27 +10,44 @@ add "&raw" to the end of the URL within a browser.
<html> <html>
<head> <head>
<style> <style>
html, body { html, body, #editor {
height: 100%; height: 100%;
margin: 0; margin: 0;
overflow: hidden; overflow: hidden;
width: 100%; width: 100%;
} }
</style> </style>
<link href="https://cdn.jsdelivr.net/npm/graphiql@{{graphiql_version}}/graphiql.css" <link href="https://cdn.jsdelivr.net/npm/graphiql@{{graphiql_version}}/graphiql.min.css"
integrity="{{graphiql_css_sri}}"
rel="stylesheet" rel="stylesheet"
crossorigin="anonymous" /> crossorigin="anonymous" />
<script src="https://cdn.jsdelivr.net/npm/whatwg-fetch@2.0.3/fetch.min.js" <script src="https://cdn.jsdelivr.net/npm/whatwg-fetch@{{whatwg_fetch_version}}/dist/fetch.umd.js"
integrity="sha384-dcF7KoWRaRpjcNbVPUFgatYgAijf8DqW6NWuqLdfB5Sb4Cdbb8iHX7bHsl9YhpKa" integrity="{{whatwg_fetch_sri}}"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/react@{{react_version}}/umd/react.production.min.js" <script src="https://cdn.jsdelivr.net/npm/react@{{react_version}}/umd/react.production.min.js"
integrity="{{react_sri}}"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/react-dom@{{react_version}}/umd/react-dom.production.min.js" <script src="https://cdn.jsdelivr.net/npm/react-dom@{{react_version}}/umd/react-dom.production.min.js"
integrity="{{react_dom_sri}}"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/graphiql@{{graphiql_version}}/graphiql.min.js" <script src="https://cdn.jsdelivr.net/npm/graphiql@{{graphiql_version}}/graphiql.min.js"
integrity="{{graphiql_sri}}"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/subscriptions-transport-ws@{{subscriptions_transport_ws_version}}/browser/client.js"
integrity="{{subscriptions_transport_ws_sri}}"
crossorigin="anonymous"></script> crossorigin="anonymous"></script>
</head> </head>
<body> <body>
<div id="editor"></div>
{% csrf_token %}
<script type="application/javascript">
window.GRAPHENE_SETTINGS = {
{% if subscription_path %}
subscriptionPath: "{{subscription_path}}",
{% endif %}
graphiqlHeaderEditorEnabled: {{ graphiql_header_editor_enabled|yesno:"true,false" }},
};
</script>
<script src="{% static 'graphene_django/graphiql.js' %}"></script> <script src="{% static 'graphene_django/graphiql.js' %}"></script>
</body> </body>
</html> </html>

View File

@ -64,6 +64,9 @@ class Reporter(models.Model):
if self.reporter_type == 2: # quick and dirty way without enums if self.reporter_type == 2: # quick and dirty way without enums
self.__class__ = CNNReporter self.__class__ = CNNReporter
def some_method(self):
return 123
class CNNReporterManager(models.Manager): class CNNReporterManager(models.Manager):
def get_queryset(self): def get_queryset(self):

View File

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

View File

@ -1,31 +1,50 @@
from collections import namedtuple
import pytest import pytest
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from graphene import NonNull
from py.test import raises from py.test import raises
import graphene import graphene
from graphene import NonNull
from graphene.relay import ConnectionField, Node 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 graphene.types.json import JSONString
from ..compat import JSONField, ArrayField, HStoreField, RangeField, MissingType from ..compat import (
from ..converter import convert_django_field, convert_django_field_with_choices ArrayField,
HStoreField,
JSONField,
PGJSONField,
MissingType,
RangeField,
)
from ..converter import (
convert_django_field,
convert_django_field_with_choices,
generate_enum_name,
)
from ..registry import Registry from ..registry import Registry
from ..types import DjangoObjectType from ..types import DjangoObjectType
from .models import Article, Film, FilmDetails, Reporter from .models import Article, Film, FilmDetails, Reporter
# from graphene.core.types.custom_scalars import DateTime, Time, JSONString # from graphene.core.types.custom_scalars import DateTime, Time, JSONString
def assert_conversion(django_field, graphene_field, *args, **kwargs): 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) graphene_type = convert_django_field(field)
assert isinstance(graphene_type, graphene_field) assert isinstance(graphene_type, graphene_field)
field = graphene_type.Field() field = graphene_type.Field()
assert field.description == "Custom Help Text" 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: if not nonnull_field.null:
nonnull_graphene_type = convert_django_field(nonnull_field) nonnull_graphene_type = convert_django_field(nonnull_field)
nonnull_field = nonnull_graphene_type.Field() nonnull_field = nonnull_graphene_type.Field()
@ -121,7 +140,12 @@ def test_should_integer_convert_int():
def test_should_boolean_convert_boolean(): 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 assert field.type.of_type == graphene.Boolean
@ -293,6 +317,14 @@ def test_should_postgres_array_convert_list():
) )
assert isinstance(field.type, graphene.NonNull) assert isinstance(field.type, graphene.NonNull)
assert isinstance(field.type.of_type, graphene.List) assert isinstance(field.type.of_type, graphene.List)
assert isinstance(field.type.of_type.of_type, graphene.NonNull)
assert field.type.of_type.of_type.of_type == graphene.String
field = assert_conversion(
ArrayField, graphene.List, models.CharField(max_length=100, null=True)
)
assert isinstance(field.type, graphene.NonNull)
assert isinstance(field.type.of_type, graphene.List)
assert field.type.of_type.of_type == graphene.String assert field.type.of_type.of_type == graphene.String
@ -304,6 +336,17 @@ def test_should_postgres_array_multiple_convert_list():
assert isinstance(field.type, graphene.NonNull) assert isinstance(field.type, graphene.NonNull)
assert isinstance(field.type.of_type, graphene.List) assert isinstance(field.type.of_type, graphene.List)
assert isinstance(field.type.of_type.of_type, graphene.List) assert isinstance(field.type.of_type.of_type, graphene.List)
assert isinstance(field.type.of_type.of_type.of_type, graphene.NonNull)
assert field.type.of_type.of_type.of_type.of_type == graphene.String
field = assert_conversion(
ArrayField,
graphene.List,
ArrayField(models.CharField(max_length=100, null=True)),
)
assert isinstance(field.type, graphene.NonNull)
assert isinstance(field.type.of_type, graphene.List)
assert isinstance(field.type.of_type.of_type, graphene.List)
assert field.type.of_type.of_type.of_type == graphene.String assert field.type.of_type.of_type.of_type == graphene.String
@ -312,8 +355,13 @@ def test_should_postgres_hstore_convert_string():
assert_conversion(HStoreField, JSONString) assert_conversion(HStoreField, JSONString)
@pytest.mark.skipif(JSONField is MissingType, reason="JSONField should exist") @pytest.mark.skipif(PGJSONField is MissingType, reason="PGJSONField should exist")
def test_should_postgres_json_convert_string(): def test_should_postgres_json_convert_string():
assert_conversion(PGJSONField, JSONString)
@pytest.mark.skipif(JSONField is MissingType, reason="JSONField should exist")
def test_should_json_convert_string():
assert_conversion(JSONField, JSONString) assert_conversion(JSONField, JSONString)
@ -324,4 +372,25 @@ def test_should_postgres_range_convert_list():
field = assert_conversion(IntegerRangeField, graphene.List) field = assert_conversion(IntegerRangeField, graphene.List)
assert isinstance(field.type, graphene.NonNull) assert isinstance(field.type, graphene.NonNull)
assert isinstance(field.type.of_type, graphene.List) assert isinstance(field.type.of_type, graphene.List)
assert field.type.of_type.of_type == graphene.Int assert isinstance(field.type.of_type.of_type, graphene.NonNull)
assert field.type.of_type.of_type.of_type == graphene.Int
def test_generate_enum_name(graphene_settings):
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

@ -0,0 +1,377 @@
import datetime
from django.db.models import Count
import pytest
from graphene import List, NonNull, ObjectType, Schema, String
from ..fields import DjangoListField
from ..types import DjangoObjectType
from .models import Article as ArticleModel
from .models import Reporter as ReporterModel
class TestDjangoListField:
def test_only_django_object_types(self):
class TestType(ObjectType):
foo = String()
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:
model = ReporterModel
fields = ("first_name",)
list_field = DjangoListField(NonNull(Reporter))
assert isinstance(list_field.type, List)
assert isinstance(list_field.type.of_type, NonNull)
assert list_field.type.of_type.of_type is Reporter
def test_get_django_model(self):
class Reporter(DjangoObjectType):
class Meta:
model = ReporterModel
fields = ("first_name",)
list_field = DjangoListField(Reporter)
assert list_field.model is ReporterModel
def test_list_field_default_queryset(self):
class Reporter(DjangoObjectType):
class Meta:
model = ReporterModel
fields = ("first_name",)
class Query(ObjectType):
reporters = DjangoListField(Reporter)
schema = Schema(query=Query)
query = """
query {
reporters {
firstName
}
}
"""
ReporterModel.objects.create(first_name="Tara", last_name="West")
ReporterModel.objects.create(first_name="Debra", last_name="Payne")
result = schema.execute(query)
assert not result.errors
assert result.data == {
"reporters": [{"firstName": "Tara"}, {"firstName": "Debra"}]
}
def test_override_resolver(self):
class Reporter(DjangoObjectType):
class Meta:
model = ReporterModel
fields = ("first_name",)
class Query(ObjectType):
reporters = DjangoListField(Reporter)
def resolve_reporters(_, info):
return ReporterModel.objects.filter(first_name="Tara")
schema = Schema(query=Query)
query = """
query {
reporters {
firstName
}
}
"""
ReporterModel.objects.create(first_name="Tara", last_name="West")
ReporterModel.objects.create(first_name="Debra", last_name="Payne")
result = schema.execute(query)
assert not result.errors
assert result.data == {"reporters": [{"firstName": "Tara"}]}
def test_nested_list_field(self):
class Article(DjangoObjectType):
class Meta:
model = ArticleModel
fields = ("headline",)
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"},
{"headline": "Not so good news"},
],
},
{"firstName": "Debra", "articles": []},
]
}
def test_override_resolver_nested_list_field(self):
class Article(DjangoObjectType):
class Meta:
model = ArticleModel
fields = ("headline",)
class Reporter(DjangoObjectType):
class Meta:
model = ReporterModel
fields = ("first_name", "articles")
def resolve_articles(reporter, info):
return reporter.articles.filter(headline__contains="Amazing")
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": []},
]
}
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,15 +1,15 @@
import base64
import datetime import datetime
import base64
import graphene
import pytest import pytest
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from django.utils.functional import SimpleLazyObject from django.utils.functional import SimpleLazyObject
from graphene.relay import Node from graphql_relay import to_global_id
from py.test import raises from py.test import raises
from graphql_relay import to_global_id import graphene
from graphene.relay import Node
from ..compat import JSONField, MissingType from ..compat import JSONField, MissingType
from ..fields import DjangoConnectionField from ..fields import DjangoConnectionField
@ -18,8 +18,6 @@ from ..types import DjangoObjectType
from ..utils import DJANGO_FILTER_INSTALLED from ..utils import DJANGO_FILTER_INSTALLED
from .models import Article, CNNReporter, Film, FilmDetails, Reporter from .models import Article, CNNReporter, Film, FilmDetails, Reporter
pytestmark = pytest.mark.django_db
def test_should_query_only_fields(): def test_should_query_only_fields():
with raises(Exception): with raises(Exception):
@ -64,6 +62,31 @@ def test_should_query_simplelazy_objects():
assert result.data == {"reporter": {"id": "1"}} assert result.data == {"reporter": {"id": "1"}}
def test_should_query_wrapped_simplelazy_objects():
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
fields = ("id",)
class Query(graphene.ObjectType):
reporter = graphene.Field(ReporterType)
def resolve_reporter(self, info):
return SimpleLazyObject(lambda: SimpleLazyObject(lambda: Reporter(id=1)))
schema = graphene.Schema(query=Query)
query = """
query {
reporter {
id
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data == {"reporter": {"id": "1"}}
def test_should_query_well(): def test_should_query_well():
class ReporterType(DjangoObjectType): class ReporterType(DjangoObjectType):
class Meta: class Meta:
@ -146,9 +169,6 @@ def test_should_query_postgres_fields():
def test_should_node(): def test_should_node():
# reset_global_registry()
# Node._meta.registry = get_global_registry()
class ReporterNode(DjangoObjectType): class ReporterNode(DjangoObjectType):
class Meta: class Meta:
model = Reporter model = Reporter
@ -587,7 +607,7 @@ def test_should_query_node_multiple_filtering():
assert result.data == expected 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 graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST = True
class ReporterType(DjangoObjectType): class ReporterType(DjangoObjectType):
@ -626,7 +646,7 @@ def test_should_enforce_first_or_last():
assert result.data == expected 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 graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 100
class ReporterType(DjangoObjectType): class ReporterType(DjangoObjectType):
@ -637,6 +657,8 @@ def test_should_error_if_first_is_greater_than_max():
class Query(graphene.ObjectType): class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType) all_reporters = DjangoConnectionField(ReporterType)
assert Query.all_reporters.max_limit == 100
r = Reporter.objects.create( r = Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
) )
@ -664,10 +686,8 @@ def test_should_error_if_first_is_greater_than_max():
) )
assert result.data == expected assert result.data == expected
graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST = False
def test_should_error_if_last_is_greater_than_max(graphene_settings):
def test_should_error_if_last_is_greater_than_max():
graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 100 graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 100
class ReporterType(DjangoObjectType): class ReporterType(DjangoObjectType):
@ -678,6 +698,8 @@ def test_should_error_if_last_is_greater_than_max():
class Query(graphene.ObjectType): class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType) all_reporters = DjangoConnectionField(ReporterType)
assert Query.all_reporters.max_limit == 100
r = Reporter.objects.create( r = Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
) )
@ -705,8 +727,6 @@ def test_should_error_if_last_is_greater_than_max():
) )
assert result.data == expected assert result.data == expected
graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST = False
def test_should_not_error_if_last_and_first_not_greater_than_max(): def test_should_not_error_if_last_and_first_not_greater_than_max():
graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 1 graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 1
@ -974,7 +994,7 @@ def test_should_query_connectionfields_with_manager():
schema = graphene.Schema(query=Query) schema = graphene.Schema(query=Query)
query = """ query = """
query ReporterLastQuery { query ReporterLastQuery {
allReporters(first: 2) { allReporters(first: 1) {
edges { edges {
node { node {
id id
@ -1237,6 +1257,134 @@ def test_should_resolve_get_queryset_connectionfields():
assert result.data == expected assert result.data == expected
def test_connection_should_limit_after_to_list_length():
reporter_1 = Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
reporter_2 = Reporter.objects.create(
first_name="Some", last_name="Guy", email="someguy@cnn.com", a_choice=1
)
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
schema = graphene.Schema(query=Query)
query = """
query ReporterPromiseConnectionQuery ($after: String) {
allReporters(first: 1 after: $after) {
edges {
node {
id
}
}
}
}
"""
after = base64.b64encode(b"arrayconnection:10").decode()
result = schema.execute(query, variable_values=dict(after=after))
expected = {"allReporters": {"edges": []}}
assert not result.errors
assert result.data == expected
REPORTERS = [
dict(
first_name="First {}".format(i),
last_name="Last {}".format(i),
email="johndoe+{}@example.com".format(i),
a_choice=1,
)
for i in range(6)
]
def test_should_return_max_limit(graphene_settings):
graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 4
reporters = [Reporter(**kwargs) for kwargs in REPORTERS]
Reporter.objects.bulk_create(reporters)
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
schema = graphene.Schema(query=Query)
query = """
query AllReporters {
allReporters {
edges {
node {
id
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert len(result.data["allReporters"]["edges"]) == 4
def test_should_have_next_page(graphene_settings):
graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 4
reporters = [Reporter(**kwargs) for kwargs in REPORTERS]
Reporter.objects.bulk_create(reporters)
db_reporters = Reporter.objects.all()
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
schema = graphene.Schema(query=Query)
query = """
query AllReporters($first: Int, $after: String) {
allReporters(first: $first, after: $after) {
pageInfo {
hasNextPage
endCursor
}
edges {
node {
id
}
}
}
}
"""
result = schema.execute(query, variable_values={})
assert not result.errors
assert len(result.data["allReporters"]["edges"]) == 4
assert result.data["allReporters"]["pageInfo"]["hasNextPage"]
last_result = result.data["allReporters"]["pageInfo"]["endCursor"]
result2 = schema.execute(query, variable_values=dict(first=4, after=last_result))
assert not result2.errors
assert len(result2.data["allReporters"]["edges"]) == 2
assert not result2.data["allReporters"]["pageInfo"]["hasNextPage"]
gql_reporters = (
result.data["allReporters"]["edges"] + result2.data["allReporters"]["edges"]
)
assert {to_global_id("ReporterType", reporter.id) for reporter in db_reporters} == {
gql_reporter["node"]["id"] for gql_reporter in gql_reporters
}
def test_should_preserve_prefetch_related(django_assert_num_queries): def test_should_preserve_prefetch_related(django_assert_num_queries):
class ReporterType(DjangoObjectType): class ReporterType(DjangoObjectType):
class Meta: class Meta:
@ -1286,3 +1434,55 @@ def test_should_preserve_prefetch_related(django_assert_num_queries):
with django_assert_num_queries(3) as captured: with django_assert_num_queries(3) as captured:
result = schema.execute(query) result = schema.execute(query)
assert not result.errors 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):
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)

View File

@ -9,12 +9,11 @@ from graphene import Connection, Field, Interface, ObjectType, Schema, String
from graphene.relay import Node from graphene.relay import Node
from .. import registry from .. import registry
from ..filter import DjangoFilterConnectionField
from ..types import DjangoObjectType, DjangoObjectTypeOptions from ..types import DjangoObjectType, DjangoObjectTypeOptions
from .models import Article as ArticleModel from .models import Article as ArticleModel
from .models import Reporter as ReporterModel from .models import Reporter as ReporterModel
registry.reset_global_registry()
class Reporter(DjangoObjectType): class Reporter(DjangoObjectType):
"""Reporter description""" """Reporter description"""
@ -196,7 +195,6 @@ type RootQuery {
def with_local_registry(func): def with_local_registry(func):
def inner(*args, **kwargs): def inner(*args, **kwargs):
old = registry.get_global_registry() old = registry.get_global_registry()
registry.reset_global_registry()
try: try:
retval = func(*args, **kwargs) retval = func(*args, **kwargs)
except Exception as e: except Exception as e:
@ -233,6 +231,17 @@ def test_django_objecttype_fields():
assert fields == ["id", "email", "films"] 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 @with_local_registry
def test_django_objecttype_only_fields_and_fields(): def test_django_objecttype_only_fields_and_fields():
with pytest.raises(Exception): with pytest.raises(Exception):
@ -315,7 +324,79 @@ def test_django_objecttype_fields_exclude_type_checking():
class Reporter2(DjangoObjectType): class Reporter2(DjangoObjectType):
class Meta: class Meta:
model = ReporterModel 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: class TestDjangoObjectType:
@ -424,3 +505,104 @@ class TestDjangoObjectType:
} }
""" """
) )
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(
"""\
schema {
query: Query
}
type PetModelKind {
id: ID!
kind: TestsPetModelKindChoices!
}
type Query {
pet: PetModelKind
}
enum TestsPetModelKindChoices {
CAT
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(
"""\
schema {
query: Query
}
enum CustomEnumKind {
CAT
DOG
}
type PetModelKind {
id: ID!
kind: CustomEnumKind!
}
type Query {
pet: PetModelKind
}
"""
)
@with_local_registry
def test_django_objecttype_name_connection_propagation():
class Reporter(DjangoObjectType):
class Meta:
model = ReporterModel
name = "CustomReporterName"
filter_fields = ["email"]
interfaces = (Node,)
class Query(ObjectType):
reporter = Node.Field(Reporter)
reporters = DjangoFilterConnectionField(Reporter)
assert Reporter._meta.name == "CustomReporterName"
schema = str(Schema(query=Query))
assert "type CustomReporterName implements Node {" in schema
assert "type CustomReporterNameConnection {" in schema
assert "type CustomReporterNameEdge {" in schema
assert "type Reporter implements Node {" not in schema
assert "type ReporterConnection {" not in schema
assert "type ReporterEdge {" not in schema

View File

@ -1,7 +1,12 @@
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 from .models import Film, Reporter
from ..utils.testing import graphql_query
def test_get_model_fields_no_duplication(): def test_get_model_fields_no_duplication():
@ -30,3 +35,53 @@ def test_camelize():
"valueA": "value_b" "valueA": "value_b"
} }
assert camelize({0: {"field_a": ["errors"]}}) == {0: {"fieldA": ["errors"]}} assert camelize({0: {"field_a": ["errors"]}}) == {0: {"fieldA": ["errors"]}}
@pytest.mark.django_db
@patch("graphene_django.utils.testing.Client.post")
def test_graphql_test_case_op_name(post_mock):
"""
Test that `GraphQLTestCase.query()`'s `op_name` argument produces an `operationName` field.
"""
class TestClass(GraphQLTestCase):
GRAPHQL_SCHEMA = True
def runTest(self):
pass
tc = TestClass()
tc.setUpClass()
tc.query("query { }", op_name="QueryName")
body = json.loads(post_mock.call_args.args[1])
# `operationName` field from https://graphql.org/learn/serving-over-http/#post-request
assert (
"operationName",
"QueryName",
) in body.items(), "Field 'operationName' is not present in the final request."
@pytest.mark.django_db
@patch("graphene_django.utils.testing.Client.post")
def test_graphql_query_case_op_name(post_mock):
graphql_query("query { }", op_name="QueryName")
body = json.loads(post_mock.call_args.args[1])
# `operationName` field from https://graphql.org/learn/serving-over-http/#post-request
assert (
"operationName",
"QueryName",
) in body.items(), "Field 'operationName' is not present in the final request."
@pytest.fixture
def client_query(client):
def func(*args, **kwargs):
return graphql_query(*args, client=client, **kwargs)
return func
def test_pytest_fixture_usage(client_query):
response = graphql_query("query { test }")
content = json.loads(response.content)
assert content == {"data": {"test": "Hello World"}}

View File

@ -35,9 +35,15 @@ def construct_fields(
fields = OrderedDict() fields = OrderedDict()
for name, field in _model_fields: 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_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 # https://docs.djangoproject.com/en/1.10/ref/models/fields/#django.db.models.ForeignKey.related_query_name
is_no_backref = str(name).endswith("+") is_no_backref = str(name).endswith("+")
if is_not_in_only or is_excluded or is_no_backref: if is_not_in_only or is_excluded or is_no_backref:
@ -62,6 +68,71 @@ def construct_fields(
return 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): class DjangoObjectTypeOptions(ObjectTypeOptions):
model = None # type: Model model = None # type: Model
registry = None # type: Registry registry = None # type: Registry
@ -78,10 +149,10 @@ class DjangoObjectType(ObjectType):
model=None, model=None,
registry=None, registry=None,
skip_registry=False, skip_registry=False,
only_fields=(), # deprecated in favour of `fields` only_fields=None, # deprecated in favour of `fields`
fields=(), fields=None,
exclude_fields=(), # deprecated in favour of `exclude` exclude_fields=None, # deprecated in favour of `exclude`
exclude=(), exclude=None,
filter_fields=None, filter_fields=None,
filterset_class=None, filterset_class=None,
connection=None, connection=None,
@ -136,9 +207,6 @@ class DjangoObjectType(ObjectType):
"Got %s." % type(fields).__name__ "Got %s." % type(fields).__name__
) )
if fields == ALL_FIELDS:
fields = None
# Alias exclude_fields -> exclude # Alias exclude_fields -> exclude
if exclude_fields and exclude: if exclude_fields and exclude:
raise Exception("Can't set both exclude_fields and exclude") raise Exception("Can't set both exclude_fields and exclude")
@ -171,7 +239,7 @@ class DjangoObjectType(ObjectType):
connection_class = Connection connection_class = Connection
connection = connection_class.create_type( connection = connection_class.create_type(
"{}Connection".format(cls.__name__), node=cls "{}Connection".format(options.get("name") or cls.__name__), node=cls
) )
if connection is not None: if connection is not None:
@ -193,6 +261,9 @@ class DjangoObjectType(ObjectType):
_meta=_meta, interfaces=interfaces, **options _meta=_meta, interfaces=interfaces, **options
) )
# Validate fields
validate_fields(cls, model, _meta.fields, fields, exclude)
if not skip_registry: if not skip_registry:
registry.register(cls) registry.register(cls)
@ -201,12 +272,9 @@ class DjangoObjectType(ObjectType):
@classmethod @classmethod
def is_type_of(cls, root, info): def is_type_of(cls, root, info):
if isinstance(root, SimpleLazyObject):
root._setup()
root = root._wrapped
if isinstance(root, cls): if isinstance(root, cls):
return True return True
if not is_valid_django_model(type(root)): if not is_valid_django_model(root.__class__):
raise Exception(('Received incompatible instance "{}".').format(root)) raise Exception(('Received incompatible instance "{}".').format(root))
if cls._meta.model._meta.proxy: if cls._meta.model._meta.proxy:

View File

@ -0,0 +1,6 @@
import re
from unidecode import unidecode
def to_const(string):
return re.sub(r"[\W|^]+", "_", unidecode(string)).upper()

View File

@ -2,6 +2,63 @@ import json
from django.test import TestCase, Client from django.test import TestCase, Client
DEFAULT_GRAPHQL_URL = "/graphql/"
def graphql_query(
query,
op_name=None,
input_data=None,
variables=None,
headers=None,
client=None,
graphql_url=None,
):
"""
Args:
query (string) - GraphQL query to run
op_name (string) - If the query is a mutation or named query, you must
supply the op_name. For annon queries ("{ ... }"),
should be None (default).
input_data (dict) - If provided, the $input variable in GraphQL will be set
to this value. If both ``input_data`` and ``variables``,
are provided, the ``input`` field in the ``variables``
dict will be overwritten with this value.
variables (dict) - If provided, the "variables" field in GraphQL will be
set to this value.
headers (dict) - If provided, the headers in POST request to GRAPHQL_URL
will be set to this value.
client (django.test.Client) - Test client. Defaults to django.test.Client.
graphql_url (string) - URL to graphql endpoint. Defaults to "/graphql".
Returns:
Response object from client
"""
if client is None:
client = Client()
if not graphql_url:
graphql_url = DEFAULT_GRAPHQL_URL
body = {"query": query}
if op_name:
body["operationName"] = op_name
if variables:
body["variables"] = variables
if input_data:
if variables in body:
body["variables"]["input"] = input_data
else:
body["variables"] = {"input": input_data}
if headers:
resp = client.post(
graphql_url, json.dumps(body), content_type="application/json", **headers
)
else:
resp = client.post(
graphql_url, json.dumps(body), content_type="application/json"
)
return resp
class GraphQLTestCase(TestCase): class GraphQLTestCase(TestCase):
""" """
@ -9,22 +66,15 @@ class GraphQLTestCase(TestCase):
""" """
# URL to graphql endpoint # URL to graphql endpoint
GRAPHQL_URL = "/graphql/" GRAPHQL_URL = DEFAULT_GRAPHQL_URL
# Here you need to set your graphql schema for the tests
GRAPHQL_SCHEMA = None
@classmethod @classmethod
def setUpClass(cls): def setUpClass(cls):
super(GraphQLTestCase, cls).setUpClass() super(GraphQLTestCase, cls).setUpClass()
if not cls.GRAPHQL_SCHEMA:
raise AttributeError(
"Variable GRAPHQL_SCHEMA not defined in GraphQLTestCase."
)
cls._client = Client() cls._client = Client()
def query(self, query, op_name=None, input_data=None): def query(self, query, op_name=None, input_data=None, variables=None, headers=None):
""" """
Args: Args:
query (string) - GraphQL query to run query (string) - GraphQL query to run
@ -32,21 +82,26 @@ class GraphQLTestCase(TestCase):
supply the op_name. For annon queries ("{ ... }"), supply the op_name. For annon queries ("{ ... }"),
should be None (default). should be None (default).
input_data (dict) - If provided, the $input variable in GraphQL will be set input_data (dict) - If provided, the $input variable in GraphQL will be set
to this value to this value. If both ``input_data`` and ``variables``,
are provided, the ``input`` field in the ``variables``
dict will be overwritten with this value.
variables (dict) - If provided, the "variables" field in GraphQL will be
set to this value.
headers (dict) - If provided, the headers in POST request to GRAPHQL_URL
will be set to this value.
Returns: Returns:
Response object from client Response object from client
""" """
body = {"query": query} return graphql_query(
if op_name: query,
body["operation_name"] = op_name op_name=op_name,
if input_data: input_data=input_data,
body["variables"] = {"input": input_data} variables=variables,
headers=headers,
resp = self._client.post( client=self._client,
self.GRAPHQL_URL, json.dumps(body), content_type="application/json" graphql_url=self.GRAPHQL_URL,
) )
return resp
def assertResponseNoErrors(self, resp): def assertResponseNoErrors(self, resp):
""" """

View File

View File

@ -0,0 +1,10 @@
# coding: utf-8
from ..str_converters import to_const
def test_to_const():
assert to_const('snakes $1. on a "#plane') == "SNAKES_1_ON_A_PLANE"
def test_to_const_unicode():
assert to_const(u"Skoða þetta unicode stöff") == "SKODA_THETTA_UNICODE_STOFF"

View File

@ -1,8 +1,8 @@
import inspect import inspect
import six
from django.db import models from django.db import models
from django.db.models.manager import Manager 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_text
from django.utils.functional import Promise from django.utils.functional import Promise

View File

@ -15,6 +15,7 @@ from graphql.error import format_error as format_graphql_error
from graphql.error import GraphQLError from graphql.error import GraphQLError
from graphql.execution import ExecutionResult from graphql.execution import ExecutionResult
from graphql.type.schema import GraphQLSchema from graphql.type.schema import GraphQLSchema
from graphql.execution.middleware import MiddlewareManager
from .settings import graphene_settings from .settings import graphene_settings
@ -51,9 +52,27 @@ def instantiate_middleware(middlewares):
class GraphQLView(View): class GraphQLView(View):
graphiql_version = "0.13.0"
graphiql_template = "graphene/graphiql.html" graphiql_template = "graphene/graphiql.html"
react_version = "16.8.6"
# Polyfill for window.fetch.
whatwg_fetch_version = "3.2.0"
whatwg_fetch_sri = "sha256-l6HCB9TT2v89oWbDdo2Z3j+PSVypKNLA/nqfzSbM8mo="
# React and ReactDOM.
react_version = "16.13.1"
react_sri = "sha256-yUhvEmYVhZ/GGshIQKArLvySDSh6cdmdcIx0spR3UP4="
react_dom_sri = "sha256-vFt3l+illeNlwThbDUdoPTqF81M8WNSZZZt3HEjsbSU="
# The GraphiQL React app.
graphiql_version = "1.0.3"
graphiql_sri = "sha256-VR4buIDY9ZXSyCNFHFNik6uSe0MhigCzgN4u7moCOTk="
graphiql_css_sri = "sha256-LwqxjyZgqXDYbpxQJ5zLQeNcf7WVNSJ+r8yp2rnWE/E="
# The websocket transport library for subscriptions.
subscriptions_transport_ws_version = "0.9.17"
subscriptions_transport_ws_sri = (
"sha256-kCDzver8iRaIQ/SVlfrIwxaBQ/avXf9GQFJRLlErBnk="
)
schema = None schema = None
graphiql = False graphiql = False
@ -63,6 +82,7 @@ class GraphQLView(View):
root_value = None root_value = None
pretty = False pretty = False
batch = False batch = False
subscription_path = None
def __init__( def __init__(
self, self,
@ -74,6 +94,7 @@ class GraphQLView(View):
pretty=False, pretty=False,
batch=False, batch=False,
backend=None, backend=None,
subscription_path=None,
): ):
if not schema: if not schema:
schema = graphene_settings.SCHEMA schema = graphene_settings.SCHEMA
@ -86,6 +107,9 @@ class GraphQLView(View):
self.schema = self.schema or schema self.schema = self.schema or schema
if middleware is not None: if middleware is not None:
if isinstance(middleware, MiddlewareManager):
self.middleware = middleware
else:
self.middleware = list(instantiate_middleware(middleware)) self.middleware = list(instantiate_middleware(middleware))
self.executor = executor self.executor = executor
self.root_value = root_value self.root_value = root_value
@ -93,6 +117,8 @@ class GraphQLView(View):
self.graphiql = self.graphiql or graphiql self.graphiql = self.graphiql or graphiql
self.batch = self.batch or batch self.batch = self.batch or batch
self.backend = backend self.backend = backend
if subscription_path is None:
self.subscription_path = graphene_settings.SUBSCRIPTION_PATH
assert isinstance( assert isinstance(
self.schema, GraphQLSchema self.schema, GraphQLSchema
@ -128,8 +154,21 @@ class GraphQLView(View):
if show_graphiql: if show_graphiql:
return self.render_graphiql( return self.render_graphiql(
request, request,
graphiql_version=self.graphiql_version, # Dependency parameters.
whatwg_fetch_version=self.whatwg_fetch_version,
whatwg_fetch_sri=self.whatwg_fetch_sri,
react_version=self.react_version, react_version=self.react_version,
react_sri=self.react_sri,
react_dom_sri=self.react_dom_sri,
graphiql_version=self.graphiql_version,
graphiql_sri=self.graphiql_sri,
graphiql_css_sri=self.graphiql_css_sri,
subscriptions_transport_ws_version=self.subscriptions_transport_ws_version,
subscriptions_transport_ws_sri=self.subscriptions_transport_ws_sri,
# The SUBSCRIPTION_PATH setting.
subscription_path=self.subscription_path,
# GraphiQL headers tab,
graphiql_header_editor_enabled=graphene_settings.GRAPHIQL_HEADER_EDITOR_ENABLED,
) )
if self.batch: if self.batch:
@ -274,10 +313,10 @@ class GraphQLView(View):
extra_options["executor"] = self.executor extra_options["executor"] = self.executor
return document.execute( return document.execute(
root=self.get_root_value(request), root_value=self.get_root_value(request),
variables=variables, variable_values=variables,
operation_name=operation_name, operation_name=operation_name,
context=self.get_context(request), context_value=self.get_context(request),
middleware=self.get_middleware(request), middleware=self.get_middleware(request),
**extra_options **extra_options
) )

View File

@ -26,10 +26,10 @@ tests_require = [
dev_requires = [ dev_requires = [
"black==19.3b0", "black==19.10b0",
"flake8==3.7.7", "flake8==3.7.9",
"flake8-black==0.1.0", "flake8-black==0.1.1",
"flake8-bugbear==19.3.0", "flake8-bugbear==20.1.4",
] + tests_require ] + tests_require
setup( setup(
@ -48,20 +48,25 @@ setup(
"Programming Language :: Python :: 2", "Programming Language :: Python :: 2",
"Programming Language :: Python :: 2.7", "Programming Language :: Python :: 2.7",
"Programming Language :: Python :: 3", "Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.4",
"Programming Language :: Python :: 3.5",
"Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.6",
"Programming Language :: Python :: 3.7",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: Implementation :: PyPy", "Programming Language :: Python :: Implementation :: PyPy",
"Framework :: Django",
"Framework :: Django :: 1.11",
"Framework :: Django :: 2.2",
"Framework :: Django :: 3.0",
], ],
keywords="api graphql protocol rest relay graphene", keywords="api graphql protocol rest relay graphene",
packages=find_packages(exclude=["tests"]), packages=find_packages(exclude=["tests"]),
install_requires=[ install_requires=[
"six>=1.10.0", "six>=1.10.0",
"graphene>=2.1.3,<3", "graphene>=2.1.7,<3",
"graphql-core>=2.1.0,<3", "graphql-core>=2.1.0,<3",
"Django>=1.11", "Django>=1.11",
"singledispatch>=3.4.0.3", "singledispatch>=3.4.0.3",
"promise>=2.1", "promise>=2.1",
"unidecode>=1.1.1,<2",
], ],
setup_requires=["pytest-runner"], setup_requires=["pytest-runner"],
tests_require=tests_require, tests_require=tests_require,

24
tox.ini
View File

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