diff --git a/.github/stale.yml b/.github/stale.yml
new file mode 100644
index 0000000..d066ca6
--- /dev/null
+++ b/.github/stale.yml
@@ -0,0 +1,21 @@
+# Number of days of inactivity before an issue becomes stale
+daysUntilStale: 120
+# Number of days of inactivity before a stale issue is closed
+daysUntilClose: 30
+# Issues with these labels will never be considered stale
+exemptLabels:
+ - pinned
+ - security
+ - 🐛bug
+ - 📖 documentation
+ - help wanted
+ - ✨enhancement
+# Label to use when marking an issue as stale
+staleLabel: wontfix
+# Comment to post when marking an issue as stale. Set to `false` to disable
+markComment: >
+ This issue has been automatically marked as stale because it has not had
+ recent activity. It will be closed if no further activity occurs. Thank you
+ for your contributions.
+# Comment to post when closing a stale issue. Set to `false` to disable
+closeComment: false
diff --git a/.gitignore b/.gitignore
index 0b25625..150025a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -78,3 +78,5 @@ Session.vim
*~
# auto-generated tag files
tags
+.tox/
+.pytest_cache/
diff --git a/.travis.yml b/.travis.yml
index a8375ee..4b44f60 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,59 +1,92 @@
language: python
-sudo: false
-python:
-- 2.7
-- 3.4
-- 3.5
-- 3.6
+cache: pip
+dist: xenial
+
install:
-- |
- if [ "$TEST_TYPE" = build ]; then
- pip install -e .[test]
- pip install psycopg2 # Required for Django postgres fields testing
- pip install django==$DJANGO_VERSION
- python setup.py develop
- elif [ "$TEST_TYPE" = lint ]; then
- pip install flake8
- fi
-script:
-- |
- if [ "$TEST_TYPE" = lint ]; then
- echo "Checking Python code lint."
- flake8 graphene_django
- exit
- elif [ "$TEST_TYPE" = build ]; then
- py.test --cov=graphene_django graphene_django examples
- fi
-after_success:
-- |
- if [ "$TEST_TYPE" = build ]; then
- coveralls
- fi
-env:
- matrix:
- - TEST_TYPE=build DJANGO_VERSION=1.11
-matrix:
+ - pip install tox tox-travis
+
+script:
+ - tox
+
+after_success:
+ - pip install coveralls
+ - coveralls
+
+stages:
+ - test
+ - name: deploy
+ if: tag IS present
+
+jobs:
fast_finish: true
+
+ allow_failures:
+ - env: DJANGO=master
+
include:
- - python: '3.4'
- env: TEST_TYPE=build DJANGO_VERSION=2.0
- - python: '3.5'
- env: TEST_TYPE=build DJANGO_VERSION=2.0
- - python: '3.6'
- env: TEST_TYPE=build DJANGO_VERSION=2.0
- - python: '3.5'
- env: TEST_TYPE=build DJANGO_VERSION=2.1
- - python: '3.6'
- env: TEST_TYPE=build DJANGO_VERSION=2.1
- - python: '2.7'
- env: TEST_TYPE=lint
- - python: '3.6'
- env: TEST_TYPE=lint
-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"
+ - python: 2.7
+ env: DJANGO=1.11
+
+ - python: 3.5
+ env: DJANGO=1.11
+ - python: 3.5
+ env: DJANGO=2.0
+ - python: 3.5
+ env: DJANGO=2.1
+ - python: 3.5
+ env: DJANGO=2.2
+
+ - python: 3.6
+ env: DJANGO=1.11
+ - python: 3.6
+ env: DJANGO=2.0
+ - python: 3.6
+ env: DJANGO=2.1
+ - python: 3.6
+ env: DJANGO=2.2
+ - python: 3.6
+ env: DJANGO=3.0
+ - python: 3.6
+ env: DJANGO=master
+
+ - python: 3.7
+ env: DJANGO=1.11
+ - python: 3.7
+ env: DJANGO=2.0
+ - python: 3.7
+ env: DJANGO=2.1
+ - python: 3.7
+ env: DJANGO=2.2
+ - python: 3.7
+ env: DJANGO=3.0
+ - python: 3.7
+ env: DJANGO=master
+
+ - python: 3.8
+ env: DJANGO=1.11
+ - python: 3.8
+ env: DJANGO=2.0
+ - python: 3.8
+ env: DJANGO=2.1
+ - python: 3.8
+ env: DJANGO=2.2
+ - python: 3.8
+ env: DJANGO=3.0
+ - python: 3.8
+ env: DJANGO=master
+
+ - python: 3.7
+ env: TOXENV=black,flake8
+
+ - stage: deploy
+ script: skip
+ python: 3.7
+ after_success: true
+ deploy:
+ provider: pypi
+ user: syrusakbary
+ on:
+ tags: true
+ password:
+ secure: kymIFCEPUbkgRqe2NAXkWfxMmGRfWvWBOP6LIXdVdkOOkm91fU7bndPGrAjos+/7gN0Org609ZmHSlVXNMJUWcsL2or/x5LcADJ4cZDe+79qynuoRb9xs1Ri4O4SBAuVMZxuVJvs8oUzT2R11ql5vASSMtXgbX+ZDGpmPRVZStkCuXgOc4LBhbPKyl3OFy7UQFPgAEmy3Yjh4ZSKzlXheK+S6mmr60+DCIjpaA0BWPxYK9FUE0qm7JJbHLUbwsUP/QMp5MmGjwFisXCNsIe686B7QKRaiOw62eJc2R7He8AuEC8T9OM4kRwDlecSn8mMpkoSB7QWtlJ+6XdLrJFPNvtrOfgfzS9/96Qrw9WlOslk68hMlhJeRb0s2YUD8tiV3UUkvbL1mfFoS4SI9U+rojS55KhUEJWHg1w7DjoOPoZmaIL2ChRupmvrFYNAGae1cxwG3Urh+t3wYlN3gpKsRDe5GOT7Wm2tr0ad3McCpDGUwSChX59BAJXe/MoLxkKScTrMyR8yMxHOF0b4zpVn5l7xB/o2Ik4zavx5q/0rGBMK2D+5d+gpQogKShoquTPsZUwO7sB5hYeH2hqGqpeGzZtb76E2zZYd18pJ0FsBudm5+KWjYdZ+vbtGrLxdTXJ1EEtzVXm0lscykTpqUucbXSa51dhStJvW2xEEz6p3rHo=
+ distributions: "sdist bdist_wheel"
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
new file mode 100644
index 0000000..5560ba2
--- /dev/null
+++ b/CODE_OF_CONDUCT.md
@@ -0,0 +1,76 @@
+# Contributor Covenant Code of Conduct
+
+## Our Pledge
+
+In the interest of fostering an open and welcoming environment, we as
+contributors and maintainers pledge to making participation in our project and
+our community a harassment-free experience for everyone, regardless of age, body
+size, disability, ethnicity, sex characteristics, gender identity and expression,
+level of experience, education, socio-economic status, nationality, personal
+appearance, race, religion, or sexual identity and orientation.
+
+## Our Standards
+
+Examples of behavior that contributes to creating a positive environment
+include:
+
+* Using welcoming and inclusive language
+* Being respectful of differing viewpoints and experiences
+* Gracefully accepting constructive criticism
+* Focusing on what is best for the community
+* Showing empathy towards other community members
+
+Examples of unacceptable behavior by participants include:
+
+* The use of sexualized language or imagery and unwelcome sexual attention or
+ advances
+* Trolling, insulting/derogatory comments, and personal or political attacks
+* Public or private harassment
+* Publishing others' private information, such as a physical or electronic
+ address, without explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+ professional setting
+
+## Our Responsibilities
+
+Project maintainers are responsible for clarifying the standards of acceptable
+behavior and are expected to take appropriate and fair corrective action in
+response to any instances of unacceptable behavior.
+
+Project maintainers have the right and responsibility to remove, edit, or
+reject comments, commits, code, wiki edits, issues, and other contributions
+that are not aligned to this Code of Conduct, or to ban temporarily or
+permanently any contributor for other behaviors that they deem inappropriate,
+threatening, offensive, or harmful.
+
+## Scope
+
+This Code of Conduct applies both within project spaces and in public spaces
+when an individual is representing the project or its community. Examples of
+representing a project or community include using an official project e-mail
+address, posting via an official social media account, or acting as an appointed
+representative at an online or offline event. Representation of a project may be
+further defined and clarified by project maintainers.
+
+## Enforcement
+
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported by contacting the project team at me@syrusakbary.com. All
+complaints will be reviewed and investigated and will result in a response that
+is deemed necessary and appropriate to the circumstances. The project team is
+obligated to maintain confidentiality with regard to the reporter of an incident.
+Further details of specific enforcement policies may be posted separately.
+
+Project maintainers who do not follow or enforce the Code of Conduct in good
+faith may face temporary or permanent repercussions as determined by other
+members of the project's leadership.
+
+## Attribution
+
+This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
+available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
+
+[homepage]: https://www.contributor-covenant.org
+
+For answers to common questions about this code of conduct, see
+https://www.contributor-covenant.org/faq
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..f9428e9
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,62 @@
+# Contributing
+
+Thanks for helping to make graphene-django great!
+
+We welcome all kinds of contributions:
+
+- Bug fixes
+- Documentation improvements
+- New features
+- Refactoring & tidying
+
+
+## Getting started
+
+If you have a specific contribution in mind, be sure to check the [issues](https://github.com/graphql-python/graphene-django/issues) and [projects](https://github.com/graphql-python/graphene-django/projects) in progress - someone could already be working on something similar and you can help out.
+
+
+## Project setup
+
+After cloning this repo, ensure dependencies are installed by running:
+
+```sh
+make dev-setup
+```
+
+## Running tests
+
+After developing, the full test suite can be evaluated by running:
+
+```sh
+make tests
+```
+
+## Opening Pull Requests
+
+Please fork the project and open a pull request against the master branch.
+
+This will trigger a series of test and lint checks.
+
+We advise that you format and run lint locally before doing this to save time:
+
+```sh
+make format
+make lint
+```
+
+## Documentation
+
+The [documentation](http://docs.graphene-python.org/projects/django/en/latest/) is generated using the excellent [Sphinx](http://www.sphinx-doc.org/) and a custom theme.
+
+The documentation dependencies are installed by running:
+
+```sh
+cd docs
+pip install -r requirements.txt
+```
+
+Then to produce a HTML version of the documentation:
+
+```sh
+make html
+```
\ No newline at end of file
diff --git a/MANIFEST.in b/MANIFEST.in
index 3c3d4f9..4677330 100644
--- a/MANIFEST.in
+++ b/MANIFEST.in
@@ -1,2 +1,3 @@
include README.md LICENSE
recursive-include graphene_django/templates *
+recursive-include graphene_django/static *
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..b850ae8
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,29 @@
+.PHONY: dev-setup ## Install development dependencies
+dev-setup:
+ pip install -e ".[dev]"
+
+.PHONY: install-dev
+install-dev: dev-setup # Alias install-dev -> dev-setup
+
+.PHONY: tests
+tests:
+ py.test graphene_django --cov=graphene_django -vv
+
+.PHONY: test
+test: tests # Alias test -> tests
+
+.PHONY: format
+format:
+ black --exclude "/migrations/" graphene_django examples setup.py
+
+.PHONY: lint
+lint:
+ flake8 graphene_django examples
+
+.PHONY: docs ## Generate docs
+docs: dev-setup
+ cd docs && make install && make html
+
+.PHONY: docs-live ## Generate docs with live reloading
+docs-live: dev-setup
+ cd docs && make install && make livehtml
diff --git a/README.md b/README.md
index 4e0b01d..8605065 100644
--- a/README.md
+++ b/README.md
@@ -1,13 +1,29 @@
-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-Django [](https://travis-ci.org/graphql-python/graphene-django) [](https://badge.fury.io/py/graphene-django) [](https://coveralls.io/github/graphql-python/graphene-django?branch=master)
+#  Graphene-Django
A [Django](https://www.djangoproject.com/) integration for [Graphene](http://graphene-python.org/).
-## Installation
+[![travis][travis-image]][travis-url]
+[![pypi][pypi-image]][pypi-url]
+[![Anaconda-Server Badge][conda-image]][conda-url]
+[![coveralls][coveralls-image]][coveralls-url]
+
+[travis-image]: https://travis-ci.org/graphql-python/graphene-django.svg?branch=master&style=flat
+[travis-url]: https://travis-ci.org/graphql-python/graphene-django
+[pypi-image]: https://img.shields.io/pypi/v/graphene-django.svg?style=flat
+[pypi-url]: https://pypi.org/project/graphene-django/
+[coveralls-image]: https://coveralls.io/repos/github/graphql-python/graphene-django/badge.svg?branch=master
+[coveralls-url]: https://coveralls.io/github/graphql-python/graphene-django?branch=master
+[conda-image]: https://img.shields.io/conda/vn/conda-forge/graphene-django.svg
+[conda-url]: https://anaconda.org/conda-forge/graphene-django
+
+[💬 Join the community on Slack](https://join.slack.com/t/graphenetools/shared_invite/enQtOTE2MDQ1NTg4MDM1LTA4Nzk0MGU0NGEwNzUxZGNjNDQ4ZjAwNDJjMjY0OGE1ZDgxZTg4YjM2ZTc4MjE2ZTAzZjE2ZThhZTQzZTkyMmM)
+
+## Documentation
+
+[Visit the documentation to get started!](https://docs.graphene-python.org/projects/django/en/latest/)
+
+## Quickstart
For installing graphene, just run this command in your shell
@@ -20,6 +36,7 @@ pip install "graphene-django>=2.0"
```python
INSTALLED_APPS = (
# ...
+ 'django.contrib.staticfiles', # Required for GraphiQL
'graphene_django',
)
@@ -33,12 +50,12 @@ GRAPHENE = {
We need to set up a `GraphQL` endpoint in our Django app, so we can serve the queries.
```python
-from django.conf.urls import url
+from django.urls import path
from graphene_django.views import GraphQLView
urlpatterns = [
# ...
- url(r'^graphql', GraphQLView.as_view(graphiql=True)),
+ path('graphql', GraphQLView.as_view(graphiql=True)),
]
```
@@ -73,7 +90,7 @@ class Query(graphene.ObjectType):
schema = graphene.Schema(query=Query)
```
-Then you can simply query the schema:
+Then you can query the schema:
```python
query = '''
@@ -95,32 +112,8 @@ To learn more check out the following [examples](examples/):
## Contributing
-After cloning this repo, ensure dependencies are installed by running:
+See [CONTRIBUTING.md](CONTRIBUTING.md)
-```sh
-pip install -e ".[test]"
-```
+## Release Notes
-After developing, the full test suite can be evaluated by running:
-
-```sh
-py.test graphene_django --cov=graphene_django # Use -v -s for verbose mode
-```
-
-
-### Documentation
-
-The [documentation](http://docs.graphene-python.org/projects/django/en/latest/) is generated using the excellent [Sphinx](http://www.sphinx-doc.org/) and a custom theme.
-
-The documentation dependencies are installed by running:
-
-```sh
-cd docs
-pip install -r requirements.txt
-```
-
-Then to produce a HTML version of the documentation:
-
-```sh
-make html
-```
+* See [Releases page on github](https://github.com/graphql-python/graphene-django/releases)
diff --git a/README.rst b/README.rst
index a96e60f..44feaee 100644
--- a/README.rst
+++ b/README.rst
@@ -10,8 +10,14 @@ to learn how to upgrade to Graphene ``2.0``.
A `Django `__ integration for
`Graphene `__.
-Installation
-------------
+
+Documentation
+-------------
+
+`Visit the documentation to get started! `__
+
+Quickstart
+----------
For installing graphene, just run this command in your shell
@@ -46,7 +52,7 @@ serve the queries.
urlpatterns = [
# ...
- url(r'^graphql', GraphQLView.as_view(graphiql=True)),
+ url(r'^graphql$', GraphQLView.as_view(graphiql=True)),
]
Examples
@@ -105,36 +111,7 @@ To learn more check out the following `examples `__:
Contributing
------------
-After cloning this repo, ensure dependencies are installed by running:
-
-.. code:: sh
-
- pip install -e ".[test]"
-
-After developing, the full test suite can be evaluated by running:
-
-.. code:: sh
-
- py.test graphene_django --cov=graphene_django # Use -v -s for verbose mode
-
-Documentation
-~~~~~~~~~~~~~
-
-The `documentation `__ is generated using the excellent
-`Sphinx `__ and a custom theme.
-
-The documentation dependencies are installed by running:
-
-.. code:: sh
-
- cd docs
- pip install -r requirements.txt
-
-Then to produce a HTML version of the documentation:
-
-.. code:: sh
-
- make html
+See `CONTRIBUTING.md `__.
.. |Graphene Logo| image:: http://graphene-python.org/favicon.png
.. |Build Status| image:: https://travis-ci.org/graphql-python/graphene-django.svg?branch=master
diff --git a/docs/Makefile b/docs/Makefile
index 7da67c3..4ae2962 100644
--- a/docs/Makefile
+++ b/docs/Makefile
@@ -48,12 +48,20 @@ help:
clean:
rm -rf $(BUILDDIR)/*
+.PHONY: install ## to install all documentation related requirements
+install:
+ pip install -r requirements.txt
+
.PHONY: html
html:
$(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html
@echo
@echo "Build finished. The HTML pages are in $(BUILDDIR)/html."
+.PHONY: livehtml ## to build and serve live-reloading documentation
+livehtml:
+ sphinx-autobuild -b html --watch ../graphene_django $(ALLSPHINXOPTS) $(BUILDDIR)/html
+
.PHONY: dirhtml
dirhtml:
$(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml
diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/docs/authorization.rst b/docs/authorization.rst
index 7a08481..63123b0 100644
--- a/docs/authorization.rst
+++ b/docs/authorization.rst
@@ -20,7 +20,7 @@ Let's use a simple example model.
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
@@ -31,10 +31,10 @@ To limit fields in a GraphQL query simply use the ``only_fields`` meta attribute
class PostNode(DjangoObjectType):
class Meta:
model = Post
- only_fields = ('title', 'content')
+ fields = ('title', 'content')
interfaces = (relay.Node, )
-conversely you can use ``exclude_fields`` meta attribute.
+conversely you can use ``exclude`` meta attribute.
.. code:: python
@@ -45,7 +45,7 @@ conversely you can use ``exclude_fields`` meta attribute.
class PostNode(DjangoObjectType):
class Meta:
model = Post
- exclude_fields = ('published', 'owner')
+ exclude = ('published', 'owner')
interfaces = (relay.Node, )
Queryset Filtering On Lists
@@ -84,7 +84,7 @@ with the context argument.
def resolve_my_posts(self, info):
# 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()
else:
return Post.objects.filter(owner=info.context.user)
@@ -96,6 +96,29 @@ schema is simple.
result = schema.execute(query, context_value=request)
+
+Global Filtering
+----------------
+
+If you are using ``DjangoObjectType`` you can define a custom `get_queryset`.
+
+.. code:: python
+
+ from graphene import relay
+ from graphene_django.types import DjangoObjectType
+ from .models import Post
+
+ class PostNode(DjangoObjectType):
+ class Meta:
+ model = Post
+
+ @classmethod
+ def get_queryset(cls, queryset, info):
+ if info.context.user.is_anonymous:
+ return queryset.filter(published=True)
+ return queryset
+
+
Filtering ID-based Node Access
------------------------------
@@ -110,11 +133,11 @@ method to your ``DjangoObjectType``.
class PostNode(DjangoObjectType):
class Meta:
model = Post
- only_fields = ('title', 'content')
+ fields = ('title', 'content')
interfaces = (relay.Node, )
@classmethod
- def get_node(cls, id, info):
+ def get_node(cls, info, id):
try:
post = cls._meta.model.objects.get(id=id)
except cls._meta.model.DoesNotExist:
@@ -131,8 +154,9 @@ Adding Login Required
To restrict users from accessing the GraphQL API page the standard Django LoginRequiredMixin_ can be used to create your own standard Django Class Based View, which includes the ``LoginRequiredMixin`` and subclasses the ``GraphQLView``.:
.. code:: python
- #views.py
-
+
+ # views.py
+
from django.contrib.auth.mixins import LoginRequiredMixin
from graphene_django.views import GraphQLView
@@ -142,15 +166,15 @@ To restrict users from accessing the GraphQL API page the standard Django LoginR
After this, you can use the new ``PrivateGraphQLView`` in the project's URL Configuration file ``url.py``:
-For Django 1.9 and below:
+For Django 1.11:
.. code:: python
urlpatterns = [
# some other urls
- url(r'^graphql', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
+ url(r'^graphql$', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
]
-
+
For Django 2.0 and above:
.. code:: python
diff --git a/docs/conf.py b/docs/conf.py
index 2ea2d55..a485d5b 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -1,6 +1,6 @@
import os
-on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
+on_rtd = os.environ.get("READTHEDOCS", None) == "True"
# -*- coding: utf-8 -*-
#
@@ -34,46 +34,44 @@ on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
- 'sphinx.ext.autodoc',
- 'sphinx.ext.intersphinx',
- 'sphinx.ext.todo',
- 'sphinx.ext.coverage',
- 'sphinx.ext.viewcode',
+ "sphinx.ext.autodoc",
+ "sphinx.ext.intersphinx",
+ "sphinx.ext.todo",
+ "sphinx.ext.coverage",
+ "sphinx.ext.viewcode",
]
if not on_rtd:
- extensions += [
- 'sphinx.ext.githubpages',
- ]
+ extensions += ["sphinx.ext.githubpages"]
# Add any paths that contain templates here, relative to this directory.
-templates_path = ['_templates']
+templates_path = ["_templates"]
# The suffix(es) of source filenames.
# You can specify multiple suffix as a list of string:
#
# source_suffix = ['.rst', '.md']
-source_suffix = '.rst'
+source_suffix = ".rst"
# The encoding of source files.
#
# source_encoding = 'utf-8-sig'
# The master toctree document.
-master_doc = 'index'
+master_doc = "index"
# General information about the project.
-project = u'Graphene Django'
-copyright = u'Graphene 2017'
-author = u'Syrus Akbary'
+project = u"Graphene Django"
+copyright = u"Graphene 2017"
+author = u"Syrus Akbary"
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
-version = u'1.0'
+version = u"1.0"
# The full version, including alpha/beta/rc tags.
-release = u'1.0.dev'
+release = u"1.0.dev"
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
@@ -94,7 +92,7 @@ language = None
# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This patterns also effect to html_static_path and html_extra_path
-exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"]
# The reST default role (used for this markup: `text`) to use for all
# documents.
@@ -116,7 +114,7 @@ exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
# show_authors = False
# The name of the Pygments (syntax highlighting) style to use.
-pygments_style = 'sphinx'
+pygments_style = "sphinx"
# A list of ignored prefixes for module index sorting.
# modindex_common_prefix = []
@@ -175,7 +173,7 @@ html_theme_path = [sphinx_graphene_theme.get_html_theme_path()]
# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
-html_static_path = ['_static']
+html_static_path = ["_static"]
# Add any extra paths that contain custom files (such as robots.txt or
# .htaccess) here, relative to this directory. These files are copied
@@ -255,34 +253,30 @@ html_static_path = ['_static']
# html_search_scorer = 'scorer.js'
# Output file base name for HTML help builder.
-htmlhelp_basename = 'Graphenedoc'
+htmlhelp_basename = "Graphenedoc"
# -- Options for LaTeX output ---------------------------------------------
latex_elements = {
- # The paper size ('letterpaper' or 'a4paper').
- #
- # 'papersize': 'letterpaper',
-
- # The font size ('10pt', '11pt' or '12pt').
- #
- # 'pointsize': '10pt',
-
- # Additional stuff for the LaTeX preamble.
- #
- # 'preamble': '',
-
- # Latex figure (float) alignment
- #
- # 'figure_align': 'htbp',
+ # The paper size ('letterpaper' or 'a4paper').
+ #
+ # 'papersize': 'letterpaper',
+ # The font size ('10pt', '11pt' or '12pt').
+ #
+ # 'pointsize': '10pt',
+ # Additional stuff for the LaTeX preamble.
+ #
+ # 'preamble': '',
+ # Latex figure (float) alignment
+ #
+ # 'figure_align': 'htbp',
}
# Grouping the document tree into LaTeX files. List of tuples
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
- (master_doc, 'Graphene.tex', u'Graphene Documentation',
- u'Syrus Akbary', 'manual'),
+ (master_doc, "Graphene.tex", u"Graphene Documentation", u"Syrus Akbary", "manual")
]
# The name of an image file (relative to this directory) to place at the top of
@@ -323,8 +317,7 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
- (master_doc, 'graphene_django', u'Graphene Django Documentation',
- [author], 1)
+ (master_doc, "graphene_django", u"Graphene Django Documentation", [author], 1)
]
# If true, show URL addresses after external links.
@@ -338,9 +331,15 @@ man_pages = [
# (source start file, target name, title, author,
# dir menu entry, description, category)
texinfo_documents = [
- (master_doc, 'Graphene-Django', u'Graphene Django Documentation',
- author, 'Graphene Django', 'One line description of project.',
- 'Miscellaneous'),
+ (
+ master_doc,
+ "Graphene-Django",
+ u"Graphene Django Documentation",
+ author,
+ "Graphene Django",
+ "One line description of project.",
+ "Miscellaneous",
+ )
]
# Documents to append as an appendix to all manuals.
@@ -414,7 +413,7 @@ epub_copyright = copyright
# epub_post_files = []
# A list of files that should not be packed into the epub file.
-epub_exclude_files = ['search.html']
+epub_exclude_files = ["search.html"]
# The depth of the table of contents in toc.ncx.
#
@@ -446,4 +445,4 @@ epub_exclude_files = ['search.html']
# Example configuration for intersphinx: refer to the Python standard library.
-intersphinx_mapping = {'https://docs.python.org/': None}
+intersphinx_mapping = {"https://docs.python.org/": None}
diff --git a/docs/debug.rst b/docs/debug.rst
index 8ef2e86..d1cbb21 100644
--- a/docs/debug.rst
+++ b/docs/debug.rst
@@ -15,7 +15,7 @@ For use the Django Debug plugin in Graphene:
* Add ``graphene_django.debug.DjangoDebugMiddleware`` into ``MIDDLEWARE`` in the ``GRAPHENE`` settings.
-* Add the ``debug`` field into the schema root ``Query`` with the value ``graphene.Field(DjangoDebug, name='__debug')``.
+* Add the ``debug`` field into the schema root ``Query`` with the value ``graphene.Field(DjangoDebug, name='_debug')``.
.. code:: python
@@ -24,7 +24,7 @@ For use the Django Debug plugin in Graphene:
class Query(graphene.ObjectType):
# ...
- debug = graphene.Field(DjangoDebug, name='__debug')
+ debug = graphene.Field(DjangoDebug, name='_debug')
schema = graphene.Schema(query=Query)
@@ -34,6 +34,7 @@ And in your ``settings.py``:
.. code:: python
GRAPHENE = {
+ ...
'MIDDLEWARE': [
'graphene_django.debug.DjangoDebugMiddleware',
]
@@ -58,11 +59,11 @@ the GraphQL request, like:
}
}
# Here is the debug field that will output the SQL queries
- __debug {
+ _debug {
sql {
rawSql
}
}
}
-Note that the ``__debug`` field must be the last field in your query.
+Note that the ``_debug`` field must be the last field in your query.
diff --git a/docs/filtering.rst b/docs/filtering.rst
index feafd40..0d37f46 100644
--- a/docs/filtering.rst
+++ b/docs/filtering.rst
@@ -14,8 +14,17 @@ You will need to install it manually, which can be done as follows:
.. code:: bash
- # You'll need to django-filter
+ # You'll need to install django-filter
pip install django-filter>=2
+
+After installing ``django-filter`` you'll need to add the application in the ``settings.py`` file:
+
+.. code:: python
+
+ INSTALLED_APPS = [
+ # ...
+ "django_filters",
+ ]
Note: The techniques below are demoed in the `cookbook example
app `__.
@@ -100,7 +109,7 @@ features of ``django-filter``. This is done by transparently creating a
``filter_fields``.
However, you may find this to be insufficient. In these cases you can
-create your own ``Filterset`` as follows:
+create your own ``FilterSet``. You can pass it directly as follows:
.. code:: python
@@ -127,6 +136,33 @@ create your own ``Filterset`` as follows:
all_animals = DjangoFilterConnectionField(AnimalNode,
filterset_class=AnimalFilter)
+You can also specify the ``FilterSet`` class using the ``filterset_class``
+parameter when defining your ``DjangoObjectType``, however, this can't be used
+in unison with the ``filter_fields`` parameter:
+
+.. code:: python
+
+ class AnimalFilter(django_filters.FilterSet):
+ # Do case-insensitive lookups on 'name'
+ name = django_filters.CharFilter(lookup_expr=['iexact'])
+
+ class Meta:
+ # Assume you have an Animal model defined with the following fields
+ model = Animal
+ fields = ['name', 'genus', 'is_domesticated']
+
+
+ class AnimalNode(DjangoObjectType):
+ class Meta:
+ model = Animal
+ filterset_class = AnimalFilter
+ interfaces = (relay.Node, )
+
+
+ class Query(ObjectType):
+ animal = relay.Node.Field(AnimalNode)
+ all_animals = DjangoFilterConnectionField(AnimalNode)
+
The context argument is passed on as the `request argument `__
in a ``django_filters.FilterSet`` instance. You can use this to customize your
filters to be context-dependent. We could modify the ``AnimalFilter`` above to
@@ -136,7 +172,7 @@ pre-filter animals owned by the authenticated user (set in ``context.user``).
class AnimalFilter(django_filters.FilterSet):
# Do case-insensitive lookups on 'name'
- name = django_filters.CharFilter(lookup_type='iexact')
+ name = django_filters.CharFilter(lookup_type=['iexact'])
class Meta:
model = Animal
@@ -146,3 +182,49 @@ pre-filter animals owned by the authenticated user (set in ``context.user``).
def qs(self):
# The query context can be found in self.request.
return super(AnimalFilter, self).qs.filter(owner=self.request.user)
+
+
+Ordering
+--------
+
+You can use ``OrderFilter`` to define how you want your returned results to be ordered.
+
+Extend the tuple of fields if you want to order by more than one field.
+
+.. code:: python
+
+ from django_filters import FilterSet, OrderingFilter
+
+ class UserFilter(FilterSet):
+ class Meta:
+ model = UserModel
+
+ order_by = OrderingFilter(
+ fields=(
+ ('created_at', 'created_at'),
+ )
+ )
+
+ class Group(DjangoObjectType):
+ users = DjangoFilterConnectionField(Ticket, filterset_class=UserFilter)
+
+ class Meta:
+ name = 'Group'
+ model = GroupModel
+ interfaces = (relay.Node,)
+
+ def resolve_users(self, info, **kwargs):
+ return UserFilter(kwargs).qs
+
+
+with this set up, you can now order the users under group:
+
+.. code::
+
+ query {
+ group(id: "xxx") {
+ users(orderBy: "-created_at") {
+ xxx
+ }
+ }
+ }
diff --git a/docs/form-mutations.rst b/docs/form-mutations.rst
deleted file mode 100644
index e721a78..0000000
--- a/docs/form-mutations.rst
+++ /dev/null
@@ -1,68 +0,0 @@
-Integration with Django forms
-=============================
-
-Graphene-Django comes with mutation classes that will convert the fields on Django forms into inputs on a mutation.
-*Note: the API is experimental and will likely change in the future.*
-
-FormMutation
-------------
-
-.. code:: python
-
- class MyForm(forms.Form):
- name = forms.CharField()
-
- class MyMutation(FormMutation):
- class Meta:
- form_class = MyForm
-
-``MyMutation`` will automatically receive an ``input`` argument. This argument should be a ``dict`` where the key is ``name`` and the value is a string.
-
-ModelFormMutation
------------------
-
-``ModelFormMutation`` will pull the fields from a ``ModelForm``.
-
-.. code:: python
-
- class Pet(models.Model):
- name = models.CharField()
-
- class PetForm(forms.ModelForm):
- class Meta:
- model = Pet
- fields = ('name',)
-
- # This will get returned when the mutation completes successfully
- class PetType(DjangoObjectType):
- class Meta:
- model = Pet
-
- class PetMutation(DjangoModelFormMutation):
- class Meta:
- form_class = PetForm
-
-``PetMutation`` will grab the fields from ``PetForm`` and turn them into inputs. If the form is valid then the mutation
-will lookup the ``DjangoObjectType`` for the ``Pet`` model and return that under the key ``pet``. Otherwise it will
-return a list of errors.
-
-You can change the input name (default is ``input``) and the return field name (default is the model name lowercase).
-
-.. code:: python
-
- class PetMutation(DjangoModelFormMutation):
- class Meta:
- form_class = PetForm
- input_field_name = 'data'
- return_field_name = 'my_pet'
-
-Form validation
----------------
-
-Form mutations will call ``is_valid()`` on your forms.
-
-If the form is valid then ``form_valid(form, info)`` is called on the mutation. Override this method to change how
-the form is saved or to return a different Graphene object type.
-
-If the form is *not* valid then a list of errors will be returned. These errors have two fields: ``field``, a string
-containing the name of the invalid form field, and ``messages``, a list of strings with the validation messages.
diff --git a/docs/index.rst b/docs/index.rst
index 7c64ae7..602f8dd 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -1,16 +1,34 @@
Graphene-Django
===============
-Contents:
+Welcome to the Graphene-Django docs.
+
+Graphene-Django is built on top of `Graphene `__.
+Graphene-Django provides some additional abstractions that make it easy to add GraphQL functionality to your Django project.
+
+First time? We recommend you start with the installation guide to get set up and the basic tutorial.
+It is worth reading the `core graphene docs `__ to familiarize yourself with the basic utilities.
+
+Core tenants
+------------
+
+If you want to expose your data through GraphQL - read the ``Installation``, ``Schema`` and ``Queries`` section.
+
+
+For more advanced use, check out the Relay tutorial.
.. toctree::
- :maxdepth: 0
+ :maxdepth: 1
+ installation
tutorial-plain
tutorial-relay
+ schema
+ queries
+ mutations
filtering
authorization
debug
- rest-framework
- form-mutations
introspection
+ testing
+ settings
diff --git a/docs/installation.rst b/docs/installation.rst
new file mode 100644
index 0000000..048a994
--- /dev/null
+++ b/docs/installation.rst
@@ -0,0 +1,105 @@
+Installation
+============
+
+Graphene-Django takes a few seconds to install and set up.
+
+Requirements
+------------
+
+Graphene-Django currently supports the following versions of Django:
+
+* >= Django 1.11
+
+Installation
+------------
+
+.. code:: bash
+
+ pip install graphene-django
+
+**We strongly recommend pinning against a specific version of Graphene-Django because new versions could introduce breaking changes to your project.**
+
+Add ``graphene_django`` to the ``INSTALLED_APPS`` in the ``settings.py`` file of your Django project:
+
+.. code:: python
+
+ INSTALLED_APPS = [
+ ...
+ 'django.contrib.staticfiles', # Required for GraphiQL
+ 'graphene_django'
+ ]
+
+
+We need to add a ``graphql`` URL to the ``urls.py`` of your Django project:
+
+For Django 1.11:
+
+.. code:: python
+
+ from django.conf.urls import url
+ from graphene_django.views import GraphQLView
+
+ urlpatterns = [
+ # ...
+ url(r"graphql", GraphQLView.as_view(graphiql=True)),
+ ]
+
+For Django 2.0 and above:
+
+.. code:: python
+
+ from django.urls import path
+ from graphene_django.views import GraphQLView
+
+ urlpatterns = [
+ # ...
+ path("graphql", GraphQLView.as_view(graphiql=True)),
+ ]
+
+(Change ``graphiql=True`` to ``graphiql=False`` if you do not want to use the GraphiQL API browser.)
+
+Finally, define the schema location for Graphene in the ``settings.py`` file of your Django project:
+
+.. code:: python
+
+ GRAPHENE = {
+ 'SCHEMA': 'django_root.schema.schema'
+ }
+
+Where ``path.schema.schema`` is the location of the ``Schema`` object in your Django project.
+
+The most basic ``schema.py`` looks like this:
+
+.. code:: python
+
+ import graphene
+
+ class Query(graphene.ObjectType):
+ pass
+
+ schema = graphene.Schema(query=Query)
+
+
+To learn how to extend the schema object for your project, read the basic tutorial.
+
+CSRF exempt
+-----------
+
+If have enabled `CSRF protection `_ in your Django app
+you will find that it prevents your API clients from POSTing to the ``graphql`` endpoint. You can either
+update your API client to pass the CSRF token with each request (the Django docs have a guide on how to do that: https://docs.djangoproject.com/en/3.0/ref/csrf/#ajax) or you can exempt your Graphql endpoint from CSRF protection by wrapping the ``GraphQLView`` with the ``csrf_exempt``
+decorator:
+
+.. 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))),
+ ]
diff --git a/docs/introspection.rst b/docs/introspection.rst
index 0d30ee4..2097c30 100644
--- a/docs/introspection.rst
+++ b/docs/introspection.rst
@@ -1,17 +1,15 @@
Introspection Schema
====================
-Relay uses `Babel Relay
-Plugin `__
-that requires you to provide your GraphQL schema data.
+Relay Modern uses `Babel Relay Plugin `__ which requires you to provide your GraphQL schema data.
-Graphene comes with a management command for Django to dump your schema
-data to ``schema.json`` that is compatible with babel-relay-plugin.
+Graphene comes with a Django management command to dump your schema
+data to ``schema.json`` which is compatible with babel-relay-plugin.
Usage
-----
-Include ``graphene_django`` to ``INSTALLED_APPS`` in you project
+Include ``graphene_django`` to ``INSTALLED_APPS`` in your project
settings:
.. code:: python
@@ -29,12 +27,30 @@ It dumps your full introspection schema to ``schema.json`` inside your
project root directory. Point ``babel-relay-plugin`` to this file and
you're ready to use Relay with Graphene GraphQL implementation.
+The schema file is sorted to create a reproducible canonical representation.
+
+GraphQL SDL Representation
+--------------------------
+
+The schema can also be exported as a GraphQL SDL file by changing the file
+extension :
+
+.. code:: bash
+
+ ./manage.py graphql_schema --schema tutorial.quickstart.schema --out schema.graphql
+
+When exporting the schema as a ``.graphql`` file the ``--indent`` option is
+ignored.
+
+
Advanced Usage
--------------
The ``--indent`` option can be used to specify the number of indentation spaces to
be used in the output. Defaults to `None` which displays all data on a single line.
+The ``--watch`` option can be used to run ``./manage.py graphql_schema`` in watch mode, where it will automatically output a new schema every time there are file changes in your project
+
To simplify the command to ``./manage.py graphql_schema``, you can
specify the parameters in your settings.py:
@@ -42,7 +58,8 @@ specify the parameters in your settings.py:
GRAPHENE = {
'SCHEMA': 'tutorial.quickstart.schema',
- 'SCHEMA_OUTPUT': 'data/schema.json' # defaults to schema.json
+ 'SCHEMA_OUTPUT': 'data/schema.json', # defaults to schema.json,
+ 'SCHEMA_INDENT': 2, # Defaults to None (displays all data on a single line)
}
diff --git a/docs/mutations.rst b/docs/mutations.rst
new file mode 100644
index 0000000..aef32eb
--- /dev/null
+++ b/docs/mutations.rst
@@ -0,0 +1,231 @@
+Mutations
+=========
+
+Introduction
+------------
+
+Graphene-Django makes it easy to perform mutations.
+
+With Graphene-Django we can take advantage of pre-existing Django features to
+quickly build CRUD functionality, while still using the core `graphene mutation `__
+features to add custom mutations to a Django project.
+
+Simple example
+--------------
+
+.. code:: python
+
+ import graphene
+
+ from graphene_django import DjangoObjectType
+
+ from .models import Question
+
+
+ class QuestionType(DjangoObjectType):
+ class Meta:
+ model = Question
+
+
+ class QuestionMutation(graphene.Mutation):
+ class Arguments:
+ # The input arguments for this mutation
+ text = graphene.String(required=True)
+ id = graphene.ID()
+
+ # The class attributes define the response of the mutation
+ question = graphene.Field(QuestionType)
+
+ def mutate(self, info, text, id):
+ question = Question.objects.get(pk=id)
+ question.text = text
+ question.save()
+ # Notice we return an instance of this mutation
+ return QuestionMutation(question=question)
+
+
+ class Mutation(graphene.ObjectType):
+ update_question = QuestionMutation.Field()
+
+
+Django Forms
+------------
+
+Graphene-Django comes with mutation classes that will convert the fields on Django forms into inputs on a mutation.
+
+DjangoFormMutation
+~~~~~~~~~~~~~~~~~~
+
+.. code:: python
+
+ from graphene_django.forms.mutation import DjangoFormMutation
+
+ class MyForm(forms.Form):
+ name = forms.CharField()
+
+ class MyMutation(DjangoFormMutation):
+ class Meta:
+ form_class = MyForm
+
+``MyMutation`` will automatically receive an ``input`` argument. This argument should be a ``dict`` where the key is ``name`` and the value is a string.
+
+DjangoModelFormMutation
+~~~~~~~~~~~~~~~~~~~~~~~
+
+``DjangoModelFormMutation`` will pull the fields from a ``ModelForm``.
+
+.. code:: python
+
+ from graphene_django.forms.mutation import DjangoModelFormMutation
+
+ class Pet(models.Model):
+ name = models.CharField()
+
+ class PetForm(forms.ModelForm):
+ class Meta:
+ model = Pet
+ fields = ('name',)
+
+ # This will get returned when the mutation completes successfully
+ class PetType(DjangoObjectType):
+ class Meta:
+ model = Pet
+
+ class PetMutation(DjangoModelFormMutation):
+ pet = Field(PetType)
+
+ class Meta:
+ form_class = PetForm
+
+``PetMutation`` will grab the fields from ``PetForm`` and turn them into inputs. If the form is valid then the mutation
+will lookup the ``DjangoObjectType`` for the ``Pet`` model and return that under the key ``pet``. Otherwise it will
+return a list of errors.
+
+You can change the input name (default is ``input``) and the return field name (default is the model name lowercase).
+
+.. code:: python
+
+ class PetMutation(DjangoModelFormMutation):
+ class Meta:
+ form_class = PetForm
+ input_field_name = 'data'
+ return_field_name = 'my_pet'
+
+Form validation
+~~~~~~~~~~~~~~~
+
+Form mutations will call ``is_valid()`` on your forms.
+
+If the form is valid then the class method ``perform_mutate(form, info)`` is called on the mutation. Override this method
+to change how the form is saved or to return a different Graphene object type.
+
+If the form is *not* valid then a list of errors will be returned. These errors have two fields: ``field``, a string
+containing the name of the invalid form field, and ``messages``, a list of strings with the validation messages.
+
+
+Django REST Framework
+---------------------
+
+You can re-use your Django Rest Framework serializer with Graphene Django mutations.
+
+You can create a Mutation based on a serializer by using the `SerializerMutation` base class:
+
+.. code:: python
+
+ from graphene_django.rest_framework.mutation import SerializerMutation
+
+ class MyAwesomeMutation(SerializerMutation):
+ class Meta:
+ serializer_class = MySerializer
+
+
+Create/Update Operations
+~~~~~~~~~~~~~~~~~~~~~~~~
+
+By default ModelSerializers accept create and update operations. To
+customize this use the `model_operations` attribute on the ``SerializerMutation`` class.
+
+The update operation looks up models by the primary key by default. You can
+customize the look up with the ``lookup_field`` attribute on the ``SerializerMutation`` class.
+
+.. code:: python
+
+ from graphene_django.rest_framework.mutation import SerializerMutation
+ from .serializers import MyModelSerializer
+
+
+ class AwesomeModelMutation(SerializerMutation):
+ class Meta:
+ serializer_class = MyModelSerializer
+ model_operations = ['create', 'update']
+ lookup_field = 'id'
+
+Overriding Update Queries
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Use the method ``get_serializer_kwargs`` to override how updates are applied.
+
+.. code:: python
+
+ from graphene_django.rest_framework.mutation import SerializerMutation
+ from .serializers import MyModelSerializer
+
+
+ class AwesomeModelMutation(SerializerMutation):
+ class Meta:
+ serializer_class = MyModelSerializer
+
+ @classmethod
+ def get_serializer_kwargs(cls, root, info, **input):
+ if 'id' in input:
+ instance = Post.objects.filter(
+ id=input['id'], owner=info.context.user
+ ).first()
+ if instance:
+ return {'instance': instance, 'data': input, 'partial': True}
+
+ else:
+ raise http.Http404
+
+ return {'data': input, 'partial': True}
+
+
+
+Relay
+-----
+
+You can use relay with mutations. A Relay mutation must inherit from
+``ClientIDMutation`` and implement the ``mutate_and_get_payload`` method:
+
+.. code:: python
+
+ import graphene
+ from graphene import relay
+ from graphene_django import DjangoObjectType
+ from graphql_relay import from_global_id
+
+ from .queries import QuestionType
+
+
+ class QuestionMutation(relay.ClientIDMutation):
+ class Input:
+ text = graphene.String(required=True)
+ id = graphene.ID()
+
+ question = graphene.Field(QuestionType)
+
+ @classmethod
+ def mutate_and_get_payload(cls, root, info, text, id):
+ question = Question.objects.get(pk=from_global_id(id)[1])
+ question.text = text
+ question.save()
+ return QuestionMutation(question=question)
+
+Notice that the ``class Arguments`` is renamed to ``class Input`` with relay.
+This is due to a deprecation of ``class Arguments`` in graphene 2.0.
+
+Relay ClientIDMutation accept a ``clientIDMutation`` argument.
+This argument is also sent back to the client with the mutation result
+(you do not have to do anything). For services that manage
+a pool of many GraphQL requests in bulk, the ``clientIDMutation``
+allows you to match up a specific mutation with the response.
diff --git a/docs/queries.rst b/docs/queries.rst
new file mode 100644
index 0000000..36cdab1
--- /dev/null
+++ b/docs/queries.rst
@@ -0,0 +1,419 @@
+Queries & ObjectTypes
+=====================
+
+Introduction
+------------
+
+Graphene-Django offers a host of features for performing GraphQL queries.
+
+Graphene-Django ships with a special ``DjangoObjectType`` that automatically transforms a Django Model
+into a ``ObjectType`` for you.
+
+
+Full example
+~~~~~~~~~~~~
+
+.. code:: python
+
+ # my_app/schema.py
+
+ import graphene
+
+ from graphene_django.types import DjangoObjectType
+ from .models import Question
+
+
+ class QuestionType(DjangoObjectType):
+ class Meta:
+ model = Question
+
+
+ class Query:
+ questions = graphene.List(QuestionType)
+ question = graphene.Field(QuestionType, question_id=graphene.String())
+
+ def resolve_questions(self, info, **kwargs):
+ # Querying a list
+ return Question.objects.all()
+
+ def resolve_question(self, info, question_id):
+ # Querying a single question
+ return Question.objects.get(pk=question_id)
+
+
+Specifying which fields to include
+----------------------------------
+
+By default, ``DjangoObjectType`` will present all fields on a Model through GraphQL.
+If you only want a subset of fields to be present, you can do so using
+``fields`` or ``exclude``. It is strongly recommended that you explicitly set
+all fields that should be exposed using the fields attribute.
+This will make it less likely to result in unintentionally exposing data when
+your models change.
+
+``fields``
+~~~~~~~~~~
+
+Show **only** these fields on the model:
+
+.. code:: python
+
+ class QuestionType(DjangoObjectType):
+ class Meta:
+ model = Question
+ 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.
+
+For example:
+
+.. code:: python
+
+ class QuestionType(DjangoObjectType):
+ class Meta:
+ model = Question
+ fields = '__all__'
+
+
+``exclude``
+~~~~~~~~~~~
+
+Show all fields **except** those in ``exclude``:
+
+.. code:: python
+
+ class QuestionType(DjangoObjectType):
+ class Meta:
+ model = Question
+ exclude = ('question_text',)
+
+
+Customising fields
+------------------
+
+You can completely overwrite a field, or add new fields, to a ``DjangoObjectType`` using a Resolver:
+
+.. code:: python
+
+ class QuestionType(DjangoObjectType):
+
+ class Meta:
+ model = Question
+ fields = ('id', 'question_text')
+
+ extra_field = graphene.String()
+
+ def resolve_extra_field(self, info):
+ return 'hello!'
+
+
+Choices to Enum conversion
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+By default Graphene-Django will convert any Django fields that have `choices`_
+defined into a GraphQL enum type.
+
+.. _choices: https://docs.djangoproject.com/en/2.2/ref/models/fields/#choices
+
+For example the following ``Model`` and ``DjangoObjectType``:
+
+.. code:: python
+
+ class PetModel(models.Model):
+ kind = models.CharField(max_length=100, choices=(('cat', 'Cat'), ('dog', 'Dog')))
+
+ class Pet(DjangoObjectType):
+ class Meta:
+ model = PetModel
+
+Results in the following GraphQL schema definition:
+
+.. code::
+
+ type Pet {
+ id: ID!
+ kind: PetModelKind!
+ }
+
+ enum PetModelKind {
+ CAT
+ DOG
+ }
+
+You can disable this automatic conversion by setting
+``convert_choices_to_enum`` attribute to ``False`` on the ``DjangoObjectType``
+``Meta`` class.
+
+.. code:: python
+
+ class Pet(DjangoObjectType):
+ class Meta:
+ model = PetModel
+ convert_choices_to_enum = False
+
+.. code::
+
+ type Pet {
+ id: ID!
+ kind: String!
+ }
+
+You can also set ``convert_choices_to_enum`` to a list of fields that should be
+automatically converted into enums:
+
+.. code:: python
+
+ class Pet(DjangoObjectType):
+ class Meta:
+ model = PetModel
+ convert_choices_to_enum = ['kind']
+
+**Note:** Setting ``convert_choices_to_enum = []`` is the same as setting it to
+``False``.
+
+
+Related models
+--------------
+
+Say you have the following models:
+
+.. code:: python
+
+ class Category(models.Model):
+ foo = models.CharField(max_length=256)
+
+ class Question(models.Model):
+ category = models.ForeignKey(Category, on_delete=models.CASCADE)
+
+
+When ``Question`` is published as a ``DjangoObjectType`` and you want to add ``Category`` as a query-able field like so:
+
+.. code:: python
+
+ class QuestionType(DjangoObjectType):
+ class Meta:
+ model = Question
+ fields = ('category',)
+
+Then all query-able related models must be defined as DjangoObjectType subclass,
+or they will fail to show if you are trying to query those relation fields. You only
+need to create the most basic class for this to work:
+
+.. code:: python
+
+ class CategoryType(DjangoObjectType):
+ class Meta:
+ model = Category
+
+Default QuerySet
+-----------------
+
+If you are using ``DjangoObjectType`` you can define a custom `get_queryset` method.
+Use this to control filtering on the ObjectType level instead of the Query object level.
+
+.. code:: python
+
+ from graphene_django.types import DjangoObjectType
+ from .models import Question
+
+
+ class QuestionType(DjangoObjectType):
+ class Meta:
+ model = Question
+
+ @classmethod
+ def get_queryset(cls, queryset, info):
+ if info.context.user.is_anonymous:
+ return queryset.filter(published=True)
+ return queryset
+
+Resolvers
+---------
+
+When a GraphQL query is received by the ``Schema`` object, it will map it to a "Resolver" related to it.
+
+This resolve method should follow this format:
+
+.. code:: python
+
+ def resolve_foo(self, info, **kwargs):
+
+Where "foo" is the name of the field declared in the ``Query`` object.
+
+.. code:: python
+
+ class Query:
+ foo = graphene.List(QuestionType)
+
+ def resolve_foo(self, info, **kwargs):
+ id = kwargs.get('id')
+ return QuestionModel.objects.get(id)
+
+Arguments
+~~~~~~~~~
+
+Additionally, Resolvers will receive **any arguments declared in the field definition**. This allows you to provide input arguments in your GraphQL server and can be useful for custom queries.
+
+.. code:: python
+
+ class Query:
+ question = graphene.Field(Question, foo=graphene.String(), bar=graphene.Int())
+
+ def resolve_question(self, info, foo, bar):
+ # If `foo` or `bar` are declared in the GraphQL query they will be here, else None.
+ return Question.objects.filter(foo=foo, bar=bar).first()
+
+
+Info
+~~~~
+
+The ``info`` argument passed to all resolve methods holds some useful information.
+For Graphene-Django, the ``info.context`` attribute is the ``HTTPRequest`` object
+that would be familiar to any Django developer. This gives you the full functionality
+of Django's ``HTTPRequest`` in your resolve methods, such as checking for authenticated users:
+
+.. code:: python
+
+ def resolve_questions(self, info, **kwargs):
+ # See if a user is authenticated
+ if info.context.user.is_authenticated():
+ return Question.objects.all()
+ else:
+ 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
+-----------------
+
+With Graphene-Django you are not limited to just Django Models - you can use the standard
+``ObjectType`` to create custom fields or to provide an abstraction between your internal
+Django models and your external API.
+
+.. code:: python
+
+ import graphene
+ from .models import Question
+
+
+ class MyQuestion(graphene.ObjectType):
+ text = graphene.String()
+
+
+ class Query:
+ question = graphene.Field(MyQuestion, question_id=graphene.String())
+
+ def resolve_question(self, info, question_id):
+ question = Question.objects.get(pk=question_id)
+ return MyQuestion(
+ text=question.question_text
+ )
+
+For more information and more examples, please see the `core object type documentation `__.
+
+
+Relay
+-----
+
+`Relay `__ with Graphene-Django gives us some additional features:
+
+- Pagination and slicing.
+- An abstract ``id`` value which contains enough info for the server to know its type and its id.
+
+There is one additional import and a single line of code needed to adopt this:
+
+Full example
+~~~~~~~~~~~~
+See the `Relay documentation `__ on
+the core graphene pages for more information on customizing the Relay experience.
+
+.. code:: python
+
+ from graphene import relay
+ from graphene_django import DjangoObjectType
+ from .models import Question
+
+
+ class QuestionType(DjangoObjectType):
+ class Meta:
+ model = Question
+ interfaces = (relay.Node,)
+
+
+ class QuestionConnection(relay.Connection):
+ class Meta:
+ node = QuestionType
+
+
+ class Query:
+ questions = relay.ConnectionField(QuestionConnection)
+
+ def resolve_questions(root, info, **kwargs):
+ return Question.objects.all()
+
+
+You can now execute queries like:
+
+
+.. code:: python
+
+ {
+ questions (first: 2, after: "YXJyYXljb25uZWN0aW9uOjEwNQ==") {
+ pageInfo {
+ startCursor
+ endCursor
+ hasNextPage
+ hasPreviousPage
+ }
+ edges {
+ cursor
+ node {
+ id
+ question_text
+ }
+ }
+ }
+ }
+
+Which returns:
+
+.. code:: python
+
+ {
+ "data": {
+ "questions": {
+ "pageInfo": {
+ "startCursor": "YXJyYXljb25uZWN0aW9uOjEwNg==",
+ "endCursor": "YXJyYXljb25uZWN0aW9uOjEwNw==",
+ "hasNextPage": true,
+ "hasPreviousPage": false
+ },
+ "edges": [
+ {
+ "cursor": "YXJyYXljb25uZWN0aW9uOjEwNg==",
+ "node": {
+ "id": "UGxhY2VUeXBlOjEwNw==",
+ "question_text": "How did we get here?"
+ }
+ },
+ {
+ "cursor": "YXJyYXljb25uZWN0aW9uOjEwNw==",
+ "node": {
+ "id": "UGxhY2VUeXBlOjEwOA==",
+ "name": "Where are we?"
+ }
+ }
+ ]
+ }
+ }
+ }
+
+Note that relay implements :code:`pagination` capabilities automatically, adding a :code:`pageInfo` element, and including :code:`cursor` on nodes. These elements are included in the above example for illustration.
+
+To learn more about Pagination in general, take a look at `Pagination `__ on the GraphQL community site.
diff --git a/docs/requirements.txt b/docs/requirements.txt
index 220b7cf..7c89926 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -1,3 +1,4 @@
-sphinx
+Sphinx==1.5.3
+sphinx-autobuild==0.7.1
# Docs template
http://graphene-python.org/sphinx_graphene_theme.zip
diff --git a/docs/rest-framework.rst b/docs/rest-framework.rst
deleted file mode 100644
index ce666de..0000000
--- a/docs/rest-framework.rst
+++ /dev/null
@@ -1,64 +0,0 @@
-Integration with Django Rest Framework
-======================================
-
-You can re-use your Django Rest Framework serializer with
-graphene django.
-
-
-Mutation
---------
-
-You can create a Mutation based on a serializer by using the
-`SerializerMutation` base class:
-
-.. code:: python
-
- from graphene_django.rest_framework.mutation import SerializerMutation
-
- class MyAwesomeMutation(SerializerMutation):
- class Meta:
- serializer_class = MySerializer
-
-Create/Update Operations
----------------------
-
-By default ModelSerializers accept create and update operations. To
-customize this use the `model_operations` attribute. The update
-operation looks up models by the primary key by default. You can
-customize the look up with the lookup attribute.
-
-.. code:: python
-
- from graphene_django.rest_framework.mutation import SerializerMutation
-
- class AwesomeModelMutation(SerializerMutation):
- class Meta:
- serializer_class = MyModelSerializer
- model_operations = ['create', 'update']
- lookup_field = 'id'
-
-Overriding Update Queries
--------------------------
-
-Use the method `get_serializer_kwargs` to override how
-updates are applied.
-
-.. code:: python
-
- from graphene_django.rest_framework.mutation import SerializerMutation
-
- class AwesomeModelMutation(SerializerMutation):
- class Meta:
- serializer_class = MyModelSerializer
-
- @classmethod
- def get_serializer_kwargs(cls, root, info, **input):
- if 'id' in input:
- instance = Post.objects.filter(id=input['id'], owner=info.context.user).first()
- if instance:
- return {'instance': instance, 'data': input, 'partial': True}
-
- else:
- raise http.Http404
-
- return {'data': input, 'partial': True}
diff --git a/docs/schema.py b/docs/schema.py
new file mode 100644
index 0000000..3d9b2fa
--- /dev/null
+++ b/docs/schema.py
@@ -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
\ No newline at end of file
diff --git a/docs/schema.rst b/docs/schema.rst
new file mode 100644
index 0000000..9f0c283
--- /dev/null
+++ b/docs/schema.rst
@@ -0,0 +1,50 @@
+Schema
+======
+
+The ``graphene.Schema`` object describes your data model and provides a GraphQL server with an associated set of resolve methods that know how to fetch data. The most basic schema you can create looks like this:
+
+.. code:: python
+
+ import graphene
+
+ class Query(graphene.ObjectType):
+ pass
+
+ class Mutation(graphene.ObjectType):
+ pass
+
+ schema = graphene.Schema(query=Query, mutation=Mutation)
+
+
+This schema doesn't do anything yet, but it is ready to accept new Query or Mutation fields.
+
+
+Adding to the schema
+--------------------
+
+If you have defined a ``Query`` or ``Mutation``, you can register them with the schema:
+
+.. code:: python
+
+ import graphene
+
+ import my_app.schema.Query
+ import my_app.schema.Mutation
+
+ class Query(
+ my_app.schema.Query, # Add your Query objects here
+ graphene.ObjectType
+ ):
+ pass
+
+ class Mutation(
+ my_app.schema.Mutation, # Add your Mutation objects here
+ graphene.ObjectType
+ ):
+ pass
+
+ schema = graphene.Schema(query=Query, mutation=Mutation)
+
+You can add as many mixins to the base ``Query`` and ``Mutation`` objects as you like.
+
+Read more about Schema on the `core graphene docs `__
\ No newline at end of file
diff --git a/docs/settings.rst b/docs/settings.rst
new file mode 100644
index 0000000..5a7e4c9
--- /dev/null
+++ b/docs/settings.rst
@@ -0,0 +1,172 @@
+Settings
+========
+
+Graphene-Django can be customised using settings. This page explains each setting and their defaults.
+
+Usage
+-----
+
+Add settings to your Django project by creating a Dictonary with name ``GRAPHENE`` in the project's ``settings.py``:
+
+.. code:: python
+
+ GRAPHENE = {
+ ...
+ }
+
+
+``SCHEMA``
+----------
+
+The location of the top-level ``Schema`` class.
+
+Default: ``None``
+
+.. code:: python
+
+ GRAPHENE = {
+ 'SCHEMA': 'path.to.schema.schema',
+ }
+
+
+``SCHEMA_OUTPUT``
+-----------------
+
+The name of the file where the GraphQL schema output will go.
+
+Default: ``schema.json``
+
+.. code:: python
+
+ GRAPHENE = {
+ 'SCHEMA_OUTPUT': 'schema.json',
+ }
+
+
+``SCHEMA_INDENT``
+-----------------
+
+The indentation level of the schema output.
+
+Default: ``2``
+
+.. code:: python
+
+ GRAPHENE = {
+ 'SCHEMA_INDENT': 2,
+ }
+
+
+``MIDDLEWARE``
+--------------
+
+A tuple of middleware that will be executed for each GraphQL query.
+
+See the `middleware documentation `__ for more information.
+
+Default: ``()``
+
+.. code:: python
+
+ GRAPHENE = {
+ 'MIDDLEWARE': (
+ 'path.to.my.middleware.class',
+ ),
+ }
+
+
+``RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST``
+------------------------------------------
+
+Enforces relay queries to have the ``first`` or ``last`` argument.
+
+Default: ``False``
+
+.. code:: python
+
+ GRAPHENE = {
+ 'RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST': False,
+ }
+
+
+``RELAY_CONNECTION_MAX_LIMIT``
+------------------------------
+
+The maximum size of objects that can be requested through a relay connection.
+
+Default: ``100``
+
+.. code:: python
+
+ GRAPHENE = {
+ 'RELAY_CONNECTION_MAX_LIMIT': 100,
+ }
+
+
+``CAMELCASE_ERRORS``
+------------------------------------
+
+When set to ``True`` field names in the ``errors`` object will be camel case.
+By default they will be snake case.
+
+Default: ``False``
+
+.. code:: python
+
+ GRAPHENE = {
+ 'CAMELCASE_ERRORS': False,
+ }
+
+ # result = schema.execute(...)
+ print(result.errors)
+ # [
+ # {
+ # 'field': 'test_field',
+ # 'messages': ['This field is required.'],
+ # }
+ # ]
+
+.. code:: python
+
+ GRAPHENE = {
+ 'CAMELCASE_ERRORS': True,
+ }
+
+ # result = schema.execute(...)
+ print(result.errors)
+ # [
+ # {
+ # 'field': 'testField',
+ # 'messages': ['This field is required.'],
+ # }
+ # ]
+
+
+``DJANGO_CHOICE_FIELD_ENUM_V3_NAMING``
+--------------------------------------
+
+Set to ``True`` to use the new naming format for the auto generated Enum types from Django choice fields. The new format looks like this: ``{app_label}{object_name}{field_name}Choices``
+
+Default: ``False``
+
+
+``DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME``
+--------------------------------------
+
+Define the path of a function that takes the Django choice field and returns a string to completely customise the naming for the Enum type.
+
+If set to a function then the ``DJANGO_CHOICE_FIELD_ENUM_V3_NAMING`` setting is ignored.
+
+Default: ``None``
+
+.. code:: python
+
+ # myapp.utils
+ def enum_naming(field):
+ if isinstance(field.model, User):
+ return f"CustomUserEnum{field.name.title()}"
+ return f"CustomEnum{field.name.title()}"
+
+ GRAPHENE = {
+ 'DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME': "myapp.utils.enum_naming"
+ }
diff --git a/docs/testing.rst b/docs/testing.rst
new file mode 100644
index 0000000..473a9ba
--- /dev/null
+++ b/docs/testing.rst
@@ -0,0 +1,84 @@
+Testing API calls with django
+=============================
+
+If you want to unittest your API calls derive your test case from the class `GraphQLTestCase`.
+
+Your endpoint is set through the `GRAPHQL_URL` attribute on `GraphQLTestCase`. The default endpoint is `GRAPHQL_URL = "/graphql/"`.
+
+Usage:
+
+.. code:: python
+
+ import json
+
+ from graphene_django.utils.testing import GraphQLTestCase
+ from my_project.config.schema import schema
+
+ class MyFancyTestCase(GraphQLTestCase):
+ # Here you need to inject your test case's schema
+ GRAPHQL_SCHEMA = schema
+
+ def test_some_query(self):
+ response = self.query(
+ '''
+ query {
+ myModel {
+ id
+ name
+ }
+ }
+ ''',
+ op_name='myModel'
+ )
+
+ 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_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):
+ response = self.query(
+ '''
+ mutation myMutation($input: MyMutationInput!) {
+ myMutation(input: $input) {
+ my-model {
+ id
+ name
+ }
+ }
+ }
+ ''',
+ op_name='myMutation',
+ input_data={'my_field': 'foo', 'other_field': 'bar'}
+ )
+
+ # This validates the status code and if you get errors
+ self.assertResponseNoErrors(response)
+
+ # Add some more asserts if you like
+ ...
diff --git a/docs/tutorial-plain.rst b/docs/tutorial-plain.rst
index a87b011..e80f9ab 100644
--- a/docs/tutorial-plain.rst
+++ b/docs/tutorial-plain.rst
@@ -1,12 +1,9 @@
-Introduction tutorial - Graphene and Django
+Basic Tutorial
===========================================
-Graphene has a number of additional features that are designed to make
-working with Django *really simple*.
-
-Our primary focus here is to give a good understanding of how to connect models from Django ORM to graphene object types.
-
-A good idea is to check the `graphene `__ documentation first.
+Graphene Django has a number of additional features that are designed to make
+working with Django easy. Our primary focus in this tutorial is to give a good
+understanding of how to connect models from Django ORM to graphene object types.
Set up the Django project
-------------------------
@@ -91,7 +88,7 @@ Don't forget to create & run migrations:
python manage.py makemigrations
python manage.py migrate
-
+
Load some test data
^^^^^^^^^^^^^^^^^^^
@@ -108,7 +105,7 @@ following:
$ python ./manage.py loaddata ingredients
Installed 6 object(s) from 1 fixture(s)
-
+
Alternatively you can use the Django admin interface to create some data
yourself. You'll need to run the development server (see below), and
create a login for yourself too (``./manage.py createsuperuser``).
@@ -255,7 +252,7 @@ aforementioned GraphiQL we specify that on the parameters with ``graphiql=True``
urlpatterns = [
url(r'^admin/', admin.site.urls),
- url(r'^graphql', GraphQLView.as_view(graphiql=True)),
+ url(r'^graphql$', GraphQLView.as_view(graphiql=True)),
]
@@ -273,7 +270,7 @@ as explained above, we can do so here using:
urlpatterns = [
url(r'^admin/', admin.site.urls),
- url(r'^graphql', GraphQLView.as_view(graphiql=True, schema=schema)),
+ url(r'^graphql$', GraphQLView.as_view(graphiql=True, schema=schema)),
]
@@ -289,7 +286,7 @@ from the command line.
$ python ./manage.py runserver
Performing system checks...
- Django version 1.9, using settings 'cookbook.settings'
+ Django version 1.11, using settings 'cookbook.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
@@ -420,74 +417,17 @@ Getting single objects
So far, we have been able to fetch list of objects and follow relation. But what about single objects?
We can update our schema to support that, by adding new query for ``ingredient`` and ``category`` and adding arguments, so we can query for specific objects.
+Add the **Highlighted** lines to ``cookbook/ingredients/schema.py``
-.. code:: python
-
- import graphene
-
- from graphene_django.types import DjangoObjectType
-
- from cookbook.ingredients.models import Category, Ingredient
-
-
- class CategoryType(DjangoObjectType):
- class Meta:
- model = Category
-
-
- class IngredientType(DjangoObjectType):
- class Meta:
- model = Ingredient
-
-
- class Query(object):
- category = graphene.Field(CategoryType,
- id=graphene.Int(),
- name=graphene.String())
- all_categories = graphene.List(CategoryType)
-
-
- ingredient = graphene.Field(IngredientType,
- id=graphene.Int(),
- name=graphene.String())
- all_ingredients = graphene.List(IngredientType)
-
- def resolve_all_categories(self, info, **kwargs):
- return Category.objects.all()
-
- def resolve_all_ingredients(self, info, **kwargs):
- return Ingredient.objects.all()
-
- def resolve_category(self, info, **kwargs):
- id = kwargs.get('id')
- name = kwargs.get('name')
-
- if id is not None:
- return Category.objects.get(pk=id)
-
- if name is not None:
- return Category.objects.get(name=name)
-
- return None
-
- def resolve_ingredient(self, info, **kwargs):
- id = kwargs.get('id')
- name = kwargs.get('name')
-
- if id is not None:
- return Ingredient.objects.get(pk=id)
-
- if name is not None:
- return Ingredient.objects.get(name=name)
-
- return None
+.. literalinclude:: schema.py
+ :emphasize-lines: 19-21,25-27,36-58
Now, with the code in place, we can query for single objects.
For example, lets query ``category``:
-.. code::
+.. code::
query {
category(id: 1) {
@@ -536,3 +476,6 @@ 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``.
If you want to put things like ``django-filter`` and automatic pagination in action, you should continue with the **relay tutorial.**
+
+A good idea is to check the `graphene `__
+documentation but it is not essential to understand and use Graphene-Django in your project.
\ No newline at end of file
diff --git a/docs/tutorial-relay.rst b/docs/tutorial-relay.rst
index f2502d7..e900ea1 100644
--- a/docs/tutorial-relay.rst
+++ b/docs/tutorial-relay.rst
@@ -1,4 +1,4 @@
-Graphene and Django Tutorial using Relay
+Relay tutorial
========================================
Graphene has a number of additional features that are designed to make
@@ -147,7 +147,7 @@ Create ``cookbook/ingredients/schema.py`` and type the following:
interfaces = (relay.Node, )
- class Query(object):
+ class Query(graphene.ObjectType):
category = relay.Node.Field(CategoryNode)
all_categories = DjangoFilterConnectionField(CategoryNode)
@@ -158,7 +158,7 @@ Create ``cookbook/ingredients/schema.py`` and type the following:
The filtering functionality is provided by
`django-filter `__. See the
`usage
-documentation `__
+documentation `__
for details on the format for ``filter_fields``. While optional, this
tutorial makes use of this functionality so you will need to install
``django-filter`` for this tutorial to work:
@@ -244,7 +244,7 @@ aforementioned GraphiQL we specify that on the params with ``graphiql=True``.
urlpatterns = [
url(r'^admin/', admin.site.urls),
- url(r'^graphql', GraphQLView.as_view(graphiql=True)),
+ url(r'^graphql$', GraphQLView.as_view(graphiql=True)),
]
@@ -262,7 +262,7 @@ as explained above, we can do so here using:
urlpatterns = [
url(r'^admin/', admin.site.urls),
- url(r'^graphql', GraphQLView.as_view(graphiql=True, schema=schema)),
+ url(r'^graphql$', GraphQLView.as_view(graphiql=True, schema=schema)),
]
@@ -277,7 +277,7 @@ from the command line.
$ python ./manage.py runserver
Performing system checks...
- Django version 1.9, using settings 'cookbook.settings'
+ Django version 1.11, using settings 'cookbook.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.
@@ -345,3 +345,10 @@ Or you can get only 'meat' ingredients containing the letter 'e':
}
}
}
+
+
+
+Final Steps
+^^^^^^^^^^^
+
+We have created a GraphQL endpoint that will work with Relay, but for Relay to work it needs access to a (non python) schema. Instructions to export the schema can be found on the `Introspection Schema `__ part of this guide.
diff --git a/examples/cookbook-plain/cookbook/ingredients/admin.py b/examples/cookbook-plain/cookbook/ingredients/admin.py
index b57cbc3..042682f 100644
--- a/examples/cookbook-plain/cookbook/ingredients/admin.py
+++ b/examples/cookbook-plain/cookbook/ingredients/admin.py
@@ -5,8 +5,8 @@ from cookbook.ingredients.models import Category, Ingredient
@admin.register(Ingredient)
class IngredientAdmin(admin.ModelAdmin):
- list_display = ('id', 'name', 'category')
- list_editable = ('name', 'category')
+ list_display = ("id", "name", "category")
+ list_editable = ("name", "category")
admin.site.register(Category)
diff --git a/examples/cookbook-plain/cookbook/ingredients/apps.py b/examples/cookbook-plain/cookbook/ingredients/apps.py
index 21b4b08..3ad0143 100644
--- a/examples/cookbook-plain/cookbook/ingredients/apps.py
+++ b/examples/cookbook-plain/cookbook/ingredients/apps.py
@@ -2,6 +2,6 @@ from django.apps import AppConfig
class IngredientsConfig(AppConfig):
- name = 'cookbook.ingredients'
- label = 'ingredients'
- verbose_name = 'Ingredients'
+ name = "cookbook.ingredients"
+ label = "ingredients"
+ verbose_name = "Ingredients"
diff --git a/examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py b/examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py
new file mode 100644
index 0000000..184e79e
--- /dev/null
+++ b/examples/cookbook-plain/cookbook/ingredients/migrations/0003_auto_20181018_1746.py
@@ -0,0 +1,17 @@
+# Generated by Django 2.0 on 2018-10-18 17:46
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('ingredients', '0002_auto_20161104_0050'),
+ ]
+
+ operations = [
+ migrations.AlterModelOptions(
+ name='category',
+ options={'verbose_name_plural': 'Categories'},
+ ),
+ ]
diff --git a/examples/cookbook-plain/cookbook/ingredients/models.py b/examples/cookbook-plain/cookbook/ingredients/models.py
index 2f0eba3..5d88785 100644
--- a/examples/cookbook-plain/cookbook/ingredients/models.py
+++ b/examples/cookbook-plain/cookbook/ingredients/models.py
@@ -2,6 +2,9 @@ from django.db import models
class Category(models.Model):
+ class Meta:
+ verbose_name_plural = "Categories"
+
name = models.CharField(max_length=100)
def __str__(self):
@@ -11,7 +14,9 @@ class Category(models.Model):
class Ingredient(models.Model):
name = models.CharField(max_length=100)
notes = models.TextField(null=True, blank=True)
- category = models.ForeignKey(Category, related_name='ingredients')
+ category = models.ForeignKey(
+ Category, related_name="ingredients", on_delete=models.CASCADE
+ )
def __str__(self):
return self.name
diff --git a/examples/cookbook-plain/cookbook/ingredients/schema.py b/examples/cookbook-plain/cookbook/ingredients/schema.py
index 1f3bb18..1a54c4b 100644
--- a/examples/cookbook-plain/cookbook/ingredients/schema.py
+++ b/examples/cookbook-plain/cookbook/ingredients/schema.py
@@ -1,7 +1,7 @@
import graphene
from graphene_django.types import DjangoObjectType
-from cookbook.ingredients.models import Category, Ingredient
+from .models import Category, Ingredient
class CategoryType(DjangoObjectType):
@@ -15,27 +15,22 @@ class IngredientType(DjangoObjectType):
class Query(object):
- category = graphene.Field(CategoryType,
- id=graphene.Int(),
- name=graphene.String())
+ 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())
+ ingredient = graphene.Field(
+ IngredientType, id=graphene.Int(), name=graphene.String()
+ )
all_ingredients = graphene.List(IngredientType)
- def resolve_all_categories(self, args, context, info):
+ def resolve_all_categories(self, context):
return Category.objects.all()
- def resolve_all_ingredients(self, args, context, info):
+ def resolve_all_ingredients(self, context):
# We can easily optimize query count in the resolve method
- return Ingredient.objects.select_related('category').all()
-
- def resolve_category(self, args, context, info):
- id = args.get('id')
- name = args.get('name')
+ return Ingredient.objects.select_related("category").all()
+ def resolve_category(self, context, id=None, name=None):
if id is not None:
return Category.objects.get(pk=id)
@@ -44,10 +39,7 @@ class Query(object):
return None
- def resolve_ingredient(self, args, context, info):
- id = args.get('id')
- name = args.get('name')
-
+ def resolve_ingredient(self, context, id=None, name=None):
if id is not None:
return Ingredient.objects.get(pk=id)
diff --git a/examples/cookbook-plain/cookbook/ingredients/tests.py b/examples/cookbook-plain/cookbook/ingredients/tests.py
index 4929020..a39b155 100644
--- a/examples/cookbook-plain/cookbook/ingredients/tests.py
+++ b/examples/cookbook-plain/cookbook/ingredients/tests.py
@@ -1,2 +1 @@
-
# Create your tests here.
diff --git a/examples/cookbook-plain/cookbook/ingredients/views.py b/examples/cookbook-plain/cookbook/ingredients/views.py
index b8e4ee0..60f00ef 100644
--- a/examples/cookbook-plain/cookbook/ingredients/views.py
+++ b/examples/cookbook-plain/cookbook/ingredients/views.py
@@ -1,2 +1 @@
-
# Create your views here.
diff --git a/examples/cookbook-plain/cookbook/recipes/apps.py b/examples/cookbook-plain/cookbook/recipes/apps.py
index 1f24f13..f1e4dde 100644
--- a/examples/cookbook-plain/cookbook/recipes/apps.py
+++ b/examples/cookbook-plain/cookbook/recipes/apps.py
@@ -2,6 +2,6 @@ from django.apps import AppConfig
class RecipesConfig(AppConfig):
- name = 'cookbook.recipes'
- label = 'recipes'
- verbose_name = 'Recipes'
+ name = "cookbook.recipes"
+ label = "recipes"
+ verbose_name = "Recipes"
diff --git a/examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py b/examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py
new file mode 100644
index 0000000..7a8df49
--- /dev/null
+++ b/examples/cookbook-plain/cookbook/recipes/migrations/0003_auto_20181018_1728.py
@@ -0,0 +1,18 @@
+# Generated by Django 2.0 on 2018-10-18 17:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('recipes', '0002_auto_20161104_0106'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='recipeingredient',
+ name='unit',
+ field=models.CharField(choices=[('unit', 'Units'), ('kg', 'Kilograms'), ('l', 'Litres'), ('st', 'Shots')], max_length=20),
+ ),
+ ]
diff --git a/examples/cookbook-plain/cookbook/recipes/models.py b/examples/cookbook-plain/cookbook/recipes/models.py
index ca12fac..f6e955e 100644
--- a/examples/cookbook-plain/cookbook/recipes/models.py
+++ b/examples/cookbook-plain/cookbook/recipes/models.py
@@ -1,21 +1,28 @@
from django.db import models
-from cookbook.ingredients.models import Ingredient
+from ..ingredients.models import Ingredient
class Recipe(models.Model):
title = models.CharField(max_length=100)
instructions = models.TextField()
- __unicode__ = lambda self: self.title
+
+ def __str__(self):
+ return self.title
class RecipeIngredient(models.Model):
- recipe = models.ForeignKey(Recipe, related_name='amounts')
- ingredient = models.ForeignKey(Ingredient, related_name='used_by')
+ recipe = models.ForeignKey(Recipe, related_name="amounts", on_delete=models.CASCADE)
+ ingredient = models.ForeignKey(
+ Ingredient, related_name="used_by", on_delete=models.CASCADE
+ )
amount = models.FloatField()
- unit = models.CharField(max_length=20, choices=(
- ('unit', 'Units'),
- ('kg', 'Kilograms'),
- ('l', 'Litres'),
- ('st', 'Shots'),
- ))
+ unit = models.CharField(
+ max_length=20,
+ choices=(
+ ("unit", "Units"),
+ ("kg", "Kilograms"),
+ ("l", "Litres"),
+ ("st", "Shots"),
+ ),
+ )
diff --git a/examples/cookbook-plain/cookbook/recipes/schema.py b/examples/cookbook-plain/cookbook/recipes/schema.py
index 040c985..b029570 100644
--- a/examples/cookbook-plain/cookbook/recipes/schema.py
+++ b/examples/cookbook-plain/cookbook/recipes/schema.py
@@ -1,7 +1,7 @@
import graphene
from graphene_django.types import DjangoObjectType
-from cookbook.recipes.models import Recipe, RecipeIngredient
+from .models import Recipe, RecipeIngredient
class RecipeType(DjangoObjectType):
@@ -15,19 +15,13 @@ class RecipeIngredientType(DjangoObjectType):
class Query(object):
- recipe = graphene.Field(RecipeType,
- id=graphene.Int(),
- title=graphene.String())
+ recipe = graphene.Field(RecipeType, id=graphene.Int(), title=graphene.String())
all_recipes = graphene.List(RecipeType)
- recipeingredient = graphene.Field(RecipeIngredientType,
- id=graphene.Int())
+ recipeingredient = graphene.Field(RecipeIngredientType, id=graphene.Int())
all_recipeingredients = graphene.List(RecipeIngredientType)
- def resolve_recipe(self, args, context, info):
- id = args.get('id')
- title = args.get('title')
-
+ def resolve_recipe(self, context, id=None, title=None):
if id is not None:
return Recipe.objects.get(pk=id)
@@ -36,17 +30,15 @@ class Query(object):
return None
- def resolve_recipeingredient(self, args, context, info):
- id = args.get('id')
-
+ def resolve_recipeingredient(self, context, id=None):
if id is not None:
return RecipeIngredient.objects.get(pk=id)
return None
- def resolve_all_recipes(self, args, context, info):
+ def resolve_all_recipes(self, context):
return Recipe.objects.all()
- def resolve_all_recipeingredients(self, args, context, info):
- related = ['recipe', 'ingredient']
+ def resolve_all_recipeingredients(self, context):
+ related = ["recipe", "ingredient"]
return RecipeIngredient.objects.select_related(*related).all()
diff --git a/examples/cookbook-plain/cookbook/recipes/tests.py b/examples/cookbook-plain/cookbook/recipes/tests.py
index 4929020..a39b155 100644
--- a/examples/cookbook-plain/cookbook/recipes/tests.py
+++ b/examples/cookbook-plain/cookbook/recipes/tests.py
@@ -1,2 +1 @@
-
# Create your tests here.
diff --git a/examples/cookbook-plain/cookbook/recipes/views.py b/examples/cookbook-plain/cookbook/recipes/views.py
index b8e4ee0..60f00ef 100644
--- a/examples/cookbook-plain/cookbook/recipes/views.py
+++ b/examples/cookbook-plain/cookbook/recipes/views.py
@@ -1,2 +1 @@
-
# Create your views here.
diff --git a/examples/cookbook-plain/cookbook/schema.py b/examples/cookbook-plain/cookbook/schema.py
index f8606a7..bde9372 100644
--- a/examples/cookbook-plain/cookbook/schema.py
+++ b/examples/cookbook-plain/cookbook/schema.py
@@ -5,10 +5,12 @@ import graphene
from graphene_django.debug import DjangoDebug
-class Query(cookbook.ingredients.schema.Query,
- cookbook.recipes.schema.Query,
- graphene.ObjectType):
- debug = graphene.Field(DjangoDebug, name='__debug')
+class Query(
+ cookbook.ingredients.schema.Query,
+ cookbook.recipes.schema.Query,
+ graphene.ObjectType,
+):
+ debug = graphene.Field(DjangoDebug, name="_debug")
schema = graphene.Schema(query=Query)
diff --git a/examples/cookbook-plain/cookbook/settings.py b/examples/cookbook-plain/cookbook/settings.py
index 948292d..7eb9d56 100644
--- a/examples/cookbook-plain/cookbook/settings.py
+++ b/examples/cookbook-plain/cookbook/settings.py
@@ -21,7 +21,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = '_$=$%eqxk$8ss4n7mtgarw^5$8^d5+c83!vwatr@i_81myb=e4'
+SECRET_KEY = "_$=$%eqxk$8ss4n7mtgarw^5$8^d5+c83!vwatr@i_81myb=e4"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
@@ -32,64 +32,61 @@ ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
- 'django.contrib.admin',
- 'django.contrib.auth',
- 'django.contrib.contenttypes',
- 'django.contrib.sessions',
- 'django.contrib.messages',
- 'django.contrib.staticfiles',
- 'graphene_django',
-
- 'cookbook.ingredients.apps.IngredientsConfig',
- 'cookbook.recipes.apps.RecipesConfig',
+ "django.contrib.admin",
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.sessions",
+ "django.contrib.messages",
+ "django.contrib.staticfiles",
+ "graphene_django",
+ "cookbook.ingredients.apps.IngredientsConfig",
+ "cookbook.recipes.apps.RecipesConfig",
]
-MIDDLEWARE_CLASSES = [
- 'django.middleware.security.SecurityMiddleware',
- 'django.contrib.sessions.middleware.SessionMiddleware',
- 'django.middleware.common.CommonMiddleware',
- 'django.middleware.csrf.CsrfViewMiddleware',
- 'django.contrib.auth.middleware.AuthenticationMiddleware',
- 'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
- 'django.contrib.messages.middleware.MessageMiddleware',
- 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+MIDDLEWARE = [
+ "django.middleware.security.SecurityMiddleware",
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "django.middleware.common.CommonMiddleware",
+ "django.middleware.csrf.CsrfViewMiddleware",
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
+ "django.contrib.messages.middleware.MessageMiddleware",
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
]
GRAPHENE = {
- 'SCHEMA': 'cookbook.schema.schema',
- 'MIDDLEWARE': (
- 'graphene_django.debug.DjangoDebugMiddleware',
- )
+ "SCHEMA": "cookbook.schema.schema",
+ "SCHEMA_INDENT": 2,
+ "MIDDLEWARE": ("graphene_django.debug.DjangoDebugMiddleware",),
}
-ROOT_URLCONF = 'cookbook.urls'
+ROOT_URLCONF = "cookbook.urls"
TEMPLATES = [
{
- 'BACKEND': 'django.template.backends.django.DjangoTemplates',
- 'DIRS': [],
- 'APP_DIRS': True,
- 'OPTIONS': {
- 'context_processors': [
- 'django.template.context_processors.debug',
- 'django.template.context_processors.request',
- 'django.contrib.auth.context_processors.auth',
- 'django.contrib.messages.context_processors.messages',
- ],
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [],
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
+ ]
},
- },
+ }
]
-WSGI_APPLICATION = 'cookbook.wsgi.application'
+WSGI_APPLICATION = "cookbook.wsgi.application"
# Database
# https://docs.djangoproject.com/en/1.9/ref/settings/#databases
DATABASES = {
- 'default': {
- 'ENGINE': 'django.db.backends.sqlite3',
- 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3",
+ "NAME": os.path.join(BASE_DIR, "db.sqlite3"),
}
}
@@ -99,26 +96,20 @@ DATABASES = {
AUTH_PASSWORD_VALIDATORS = [
{
- 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
- },
- {
- 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
- },
- {
- 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
- },
- {
- 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+ "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
},
+ {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
+ {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
+ {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
# Internationalization
# https://docs.djangoproject.com/en/1.9/topics/i18n/
-LANGUAGE_CODE = 'en-us'
+LANGUAGE_CODE = "en-us"
-TIME_ZONE = 'UTC'
+TIME_ZONE = "UTC"
USE_I18N = True
@@ -130,9 +121,4 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.9/howto/static-files/
-STATIC_URL = '/static/'
-
-GRAPHENE = {
- 'SCHEMA': 'cookbook.schema.schema',
- 'SCHEMA_INDENT': 2,
-}
+STATIC_URL = "/static/"
diff --git a/examples/cookbook-plain/cookbook/urls.py b/examples/cookbook-plain/cookbook/urls.py
index 9f8755b..a64a875 100644
--- a/examples/cookbook-plain/cookbook/urls.py
+++ b/examples/cookbook-plain/cookbook/urls.py
@@ -1,10 +1,10 @@
-from django.conf.urls import url
+from django.urls import path
from django.contrib import admin
from graphene_django.views import GraphQLView
urlpatterns = [
- url(r'^admin/', admin.site.urls),
- url(r'^graphql', GraphQLView.as_view(graphiql=True)),
+ path("admin/", admin.site.urls),
+ path("graphql/", GraphQLView.as_view(graphiql=True)),
]
diff --git a/examples/cookbook-plain/requirements.txt b/examples/cookbook-plain/requirements.txt
index 362a39a..480f757 100644
--- a/examples/cookbook-plain/requirements.txt
+++ b/examples/cookbook-plain/requirements.txt
@@ -1,4 +1,4 @@
-graphene
-graphene-django
-graphql-core>=2.1rc1
-django==1.9
+graphene>=2.1,<3
+graphene-django>=2.1,<3
+graphql-core>=2.1,<3
+django==3.0.3
diff --git a/examples/cookbook/cookbook/ingredients/admin.py b/examples/cookbook/cookbook/ingredients/admin.py
index b57cbc3..042682f 100644
--- a/examples/cookbook/cookbook/ingredients/admin.py
+++ b/examples/cookbook/cookbook/ingredients/admin.py
@@ -5,8 +5,8 @@ from cookbook.ingredients.models import Category, Ingredient
@admin.register(Ingredient)
class IngredientAdmin(admin.ModelAdmin):
- list_display = ('id', 'name', 'category')
- list_editable = ('name', 'category')
+ list_display = ("id", "name", "category")
+ list_editable = ("name", "category")
admin.site.register(Category)
diff --git a/examples/cookbook/cookbook/ingredients/apps.py b/examples/cookbook/cookbook/ingredients/apps.py
index 21b4b08..3ad0143 100644
--- a/examples/cookbook/cookbook/ingredients/apps.py
+++ b/examples/cookbook/cookbook/ingredients/apps.py
@@ -2,6 +2,6 @@ from django.apps import AppConfig
class IngredientsConfig(AppConfig):
- name = 'cookbook.ingredients'
- label = 'ingredients'
- verbose_name = 'Ingredients'
+ name = "cookbook.ingredients"
+ label = "ingredients"
+ verbose_name = "Ingredients"
diff --git a/examples/cookbook/cookbook/ingredients/models.py b/examples/cookbook/cookbook/ingredients/models.py
index 2f0eba3..1e97226 100644
--- a/examples/cookbook/cookbook/ingredients/models.py
+++ b/examples/cookbook/cookbook/ingredients/models.py
@@ -11,7 +11,9 @@ class Category(models.Model):
class Ingredient(models.Model):
name = models.CharField(max_length=100)
notes = models.TextField(null=True, blank=True)
- category = models.ForeignKey(Category, related_name='ingredients')
+ category = models.ForeignKey(
+ Category, related_name="ingredients", on_delete=models.CASCADE
+ )
def __str__(self):
return self.name
diff --git a/examples/cookbook/cookbook/ingredients/schema.py b/examples/cookbook/cookbook/ingredients/schema.py
index 5ad92e8..5e5da80 100644
--- a/examples/cookbook/cookbook/ingredients/schema.py
+++ b/examples/cookbook/cookbook/ingredients/schema.py
@@ -7,24 +7,22 @@ from graphene_django.types import DjangoObjectType
# Graphene will automatically map the Category model's fields onto the CategoryNode.
# This is configured in the CategoryNode's Meta class (as you can see below)
class CategoryNode(DjangoObjectType):
-
class Meta:
model = Category
- interfaces = (Node, )
- filter_fields = ['name', 'ingredients']
+ interfaces = (Node,)
+ filter_fields = ["name", "ingredients"]
class IngredientNode(DjangoObjectType):
-
class Meta:
model = Ingredient
# Allow for some more advanced filtering here
- interfaces = (Node, )
+ interfaces = (Node,)
filter_fields = {
- 'name': ['exact', 'icontains', 'istartswith'],
- 'notes': ['exact', 'icontains'],
- 'category': ['exact'],
- 'category__name': ['exact'],
+ "name": ["exact", "icontains", "istartswith"],
+ "notes": ["exact", "icontains"],
+ "category": ["exact"],
+ "category__name": ["exact"],
}
diff --git a/examples/cookbook/cookbook/ingredients/tests.py b/examples/cookbook/cookbook/ingredients/tests.py
index 4929020..a39b155 100644
--- a/examples/cookbook/cookbook/ingredients/tests.py
+++ b/examples/cookbook/cookbook/ingredients/tests.py
@@ -1,2 +1 @@
-
# Create your tests here.
diff --git a/examples/cookbook/cookbook/ingredients/views.py b/examples/cookbook/cookbook/ingredients/views.py
index b8e4ee0..60f00ef 100644
--- a/examples/cookbook/cookbook/ingredients/views.py
+++ b/examples/cookbook/cookbook/ingredients/views.py
@@ -1,2 +1 @@
-
# Create your views here.
diff --git a/examples/cookbook/cookbook/recipes/apps.py b/examples/cookbook/cookbook/recipes/apps.py
index 1f24f13..f1e4dde 100644
--- a/examples/cookbook/cookbook/recipes/apps.py
+++ b/examples/cookbook/cookbook/recipes/apps.py
@@ -2,6 +2,6 @@ from django.apps import AppConfig
class RecipesConfig(AppConfig):
- name = 'cookbook.recipes'
- label = 'recipes'
- verbose_name = 'Recipes'
+ name = "cookbook.recipes"
+ label = "recipes"
+ verbose_name = "Recipes"
diff --git a/examples/cookbook/cookbook/recipes/models.py b/examples/cookbook/cookbook/recipes/models.py
index ca12fac..0bfb434 100644
--- a/examples/cookbook/cookbook/recipes/models.py
+++ b/examples/cookbook/cookbook/recipes/models.py
@@ -10,12 +10,17 @@ class Recipe(models.Model):
class RecipeIngredient(models.Model):
- recipe = models.ForeignKey(Recipe, related_name='amounts')
- ingredient = models.ForeignKey(Ingredient, related_name='used_by')
+ recipe = models.ForeignKey(Recipe, related_name="amounts", on_delete=models.CASCADE)
+ ingredient = models.ForeignKey(
+ Ingredient, related_name="used_by", on_delete=models.CASCADE
+ )
amount = models.FloatField()
- unit = models.CharField(max_length=20, choices=(
- ('unit', 'Units'),
- ('kg', 'Kilograms'),
- ('l', 'Litres'),
- ('st', 'Shots'),
- ))
+ unit = models.CharField(
+ max_length=20,
+ choices=(
+ ("unit", "Units"),
+ ("kg", "Kilograms"),
+ ("l", "Litres"),
+ ("st", "Shots"),
+ ),
+ )
diff --git a/examples/cookbook/cookbook/recipes/schema.py b/examples/cookbook/cookbook/recipes/schema.py
index 8018322..fbbedd8 100644
--- a/examples/cookbook/cookbook/recipes/schema.py
+++ b/examples/cookbook/cookbook/recipes/schema.py
@@ -3,24 +3,23 @@ from graphene import Node
from graphene_django.filter import DjangoFilterConnectionField
from graphene_django.types import DjangoObjectType
-class RecipeNode(DjangoObjectType):
+class RecipeNode(DjangoObjectType):
class Meta:
model = Recipe
- interfaces = (Node, )
- filter_fields = ['title','amounts']
+ interfaces = (Node,)
+ filter_fields = ["title", "amounts"]
class RecipeIngredientNode(DjangoObjectType):
-
class Meta:
model = RecipeIngredient
# Allow for some more advanced filtering here
- interfaces = (Node, )
+ interfaces = (Node,)
filter_fields = {
- 'ingredient__name': ['exact', 'icontains', 'istartswith'],
- 'recipe': ['exact'],
- 'recipe__title': ['icontains'],
+ "ingredient__name": ["exact", "icontains", "istartswith"],
+ "recipe": ["exact"],
+ "recipe__title": ["icontains"],
}
diff --git a/examples/cookbook/cookbook/recipes/tests.py b/examples/cookbook/cookbook/recipes/tests.py
index 4929020..a39b155 100644
--- a/examples/cookbook/cookbook/recipes/tests.py
+++ b/examples/cookbook/cookbook/recipes/tests.py
@@ -1,2 +1 @@
-
# Create your tests here.
diff --git a/examples/cookbook/cookbook/recipes/views.py b/examples/cookbook/cookbook/recipes/views.py
index b8e4ee0..60f00ef 100644
--- a/examples/cookbook/cookbook/recipes/views.py
+++ b/examples/cookbook/cookbook/recipes/views.py
@@ -1,2 +1 @@
-
# Create your views here.
diff --git a/examples/cookbook/cookbook/schema.py b/examples/cookbook/cookbook/schema.py
index f8606a7..bde9372 100644
--- a/examples/cookbook/cookbook/schema.py
+++ b/examples/cookbook/cookbook/schema.py
@@ -5,10 +5,12 @@ import graphene
from graphene_django.debug import DjangoDebug
-class Query(cookbook.ingredients.schema.Query,
- cookbook.recipes.schema.Query,
- graphene.ObjectType):
- debug = graphene.Field(DjangoDebug, name='__debug')
+class Query(
+ cookbook.ingredients.schema.Query,
+ cookbook.recipes.schema.Query,
+ graphene.ObjectType,
+):
+ debug = graphene.Field(DjangoDebug, name="_debug")
schema = graphene.Schema(query=Query)
diff --git a/examples/cookbook/cookbook/settings.py b/examples/cookbook/cookbook/settings.py
index 948292d..3e941b8 100644
--- a/examples/cookbook/cookbook/settings.py
+++ b/examples/cookbook/cookbook/settings.py
@@ -21,7 +21,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
-SECRET_KEY = '_$=$%eqxk$8ss4n7mtgarw^5$8^d5+c83!vwatr@i_81myb=e4'
+SECRET_KEY = "_$=$%eqxk$8ss4n7mtgarw^5$8^d5+c83!vwatr@i_81myb=e4"
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
@@ -32,64 +32,62 @@ ALLOWED_HOSTS = []
# Application definition
INSTALLED_APPS = [
- 'django.contrib.admin',
- 'django.contrib.auth',
- 'django.contrib.contenttypes',
- 'django.contrib.sessions',
- 'django.contrib.messages',
- 'django.contrib.staticfiles',
- 'graphene_django',
-
- 'cookbook.ingredients.apps.IngredientsConfig',
- 'cookbook.recipes.apps.RecipesConfig',
+ "django.contrib.admin",
+ "django.contrib.auth",
+ "django.contrib.contenttypes",
+ "django.contrib.sessions",
+ "django.contrib.messages",
+ "django.contrib.staticfiles",
+ "graphene_django",
+ "cookbook.ingredients.apps.IngredientsConfig",
+ "cookbook.recipes.apps.RecipesConfig",
+ "django_filters",
]
-MIDDLEWARE_CLASSES = [
- 'django.middleware.security.SecurityMiddleware',
- 'django.contrib.sessions.middleware.SessionMiddleware',
- 'django.middleware.common.CommonMiddleware',
- 'django.middleware.csrf.CsrfViewMiddleware',
- 'django.contrib.auth.middleware.AuthenticationMiddleware',
- 'django.contrib.auth.middleware.SessionAuthenticationMiddleware',
- 'django.contrib.messages.middleware.MessageMiddleware',
- 'django.middleware.clickjacking.XFrameOptionsMiddleware',
+MIDDLEWARE = [
+ "django.middleware.security.SecurityMiddleware",
+ "django.contrib.sessions.middleware.SessionMiddleware",
+ "django.middleware.common.CommonMiddleware",
+ "django.middleware.csrf.CsrfViewMiddleware",
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
+ "django.contrib.messages.middleware.MessageMiddleware",
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
]
GRAPHENE = {
- 'SCHEMA': 'cookbook.schema.schema',
- 'MIDDLEWARE': (
- 'graphene_django.debug.DjangoDebugMiddleware',
- )
+ "SCHEMA": "cookbook.schema.schema",
+ "SCHEMA_INDENT": 2,
+ "MIDDLEWARE": ("graphene_django.debug.DjangoDebugMiddleware",),
}
-ROOT_URLCONF = 'cookbook.urls'
+ROOT_URLCONF = "cookbook.urls"
TEMPLATES = [
{
- 'BACKEND': 'django.template.backends.django.DjangoTemplates',
- 'DIRS': [],
- 'APP_DIRS': True,
- 'OPTIONS': {
- 'context_processors': [
- 'django.template.context_processors.debug',
- 'django.template.context_processors.request',
- 'django.contrib.auth.context_processors.auth',
- 'django.contrib.messages.context_processors.messages',
- ],
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
+ "DIRS": [],
+ "APP_DIRS": True,
+ "OPTIONS": {
+ "context_processors": [
+ "django.template.context_processors.debug",
+ "django.template.context_processors.request",
+ "django.contrib.auth.context_processors.auth",
+ "django.contrib.messages.context_processors.messages",
+ ]
},
- },
+ }
]
-WSGI_APPLICATION = 'cookbook.wsgi.application'
+WSGI_APPLICATION = "cookbook.wsgi.application"
# Database
# https://docs.djangoproject.com/en/1.9/ref/settings/#databases
DATABASES = {
- 'default': {
- 'ENGINE': 'django.db.backends.sqlite3',
- 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'),
+ "default": {
+ "ENGINE": "django.db.backends.sqlite3",
+ "NAME": os.path.join(BASE_DIR, "db.sqlite3"),
}
}
@@ -99,26 +97,20 @@ DATABASES = {
AUTH_PASSWORD_VALIDATORS = [
{
- 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
- },
- {
- 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
- },
- {
- 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
- },
- {
- 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
+ "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"
},
+ {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator"},
+ {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator"},
+ {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator"},
]
# Internationalization
# https://docs.djangoproject.com/en/1.9/topics/i18n/
-LANGUAGE_CODE = 'en-us'
+LANGUAGE_CODE = "en-us"
-TIME_ZONE = 'UTC'
+TIME_ZONE = "UTC"
USE_I18N = True
@@ -130,9 +122,4 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.9/howto/static-files/
-STATIC_URL = '/static/'
-
-GRAPHENE = {
- 'SCHEMA': 'cookbook.schema.schema',
- 'SCHEMA_INDENT': 2,
-}
+STATIC_URL = "/static/"
diff --git a/examples/cookbook/cookbook/urls.py b/examples/cookbook/cookbook/urls.py
index 9f8755b..6f8a302 100644
--- a/examples/cookbook/cookbook/urls.py
+++ b/examples/cookbook/cookbook/urls.py
@@ -5,6 +5,6 @@ from graphene_django.views import GraphQLView
urlpatterns = [
- url(r'^admin/', admin.site.urls),
- url(r'^graphql', GraphQLView.as_view(graphiql=True)),
+ url(r"^admin/", admin.site.urls),
+ url(r"^graphql$", GraphQLView.as_view(graphiql=True)),
]
diff --git a/examples/cookbook/requirements.txt b/examples/cookbook/requirements.txt
index b2ace1f..4375fcc 100644
--- a/examples/cookbook/requirements.txt
+++ b/examples/cookbook/requirements.txt
@@ -1,5 +1,5 @@
-graphene
-graphene-django
-graphql-core>=2.1rc1
-django==1.9
+graphene>=2.1,<3
+graphene-django>=2.1,<3
+graphql-core>=2.1,<3
+django==3.0.3
django-filter>=2
diff --git a/examples/starwars/data.py b/examples/starwars/data.py
index 9b52006..6bdbf57 100644
--- a/examples/starwars/data.py
+++ b/examples/starwars/data.py
@@ -2,97 +2,50 @@ from .models import Character, Faction, Ship
def initialize():
- human = Character(
- name='Human'
- )
+ human = Character(name="Human")
human.save()
- droid = Character(
- name='Droid'
- )
+ droid = Character(name="Droid")
droid.save()
- rebels = Faction(
- id='1',
- name='Alliance to Restore the Republic',
- hero=human
- )
+ rebels = Faction(id="1", name="Alliance to Restore the Republic", hero=human)
rebels.save()
- empire = Faction(
- id='2',
- name='Galactic Empire',
- hero=droid
- )
+ empire = Faction(id="2", name="Galactic Empire", hero=droid)
empire.save()
- xwing = Ship(
- id='1',
- name='X-Wing',
- faction=rebels,
- )
+ xwing = Ship(id="1", name="X-Wing", faction=rebels)
xwing.save()
human.ship = xwing
human.save()
- ywing = Ship(
- id='2',
- name='Y-Wing',
- faction=rebels,
- )
+ ywing = Ship(id="2", name="Y-Wing", faction=rebels)
ywing.save()
- awing = Ship(
- id='3',
- name='A-Wing',
- faction=rebels,
- )
+ awing = Ship(id="3", name="A-Wing", faction=rebels)
awing.save()
# Yeah, technically it's Corellian. But it flew in the service of the rebels,
# so for the purposes of this demo it's a rebel ship.
- falcon = Ship(
- id='4',
- name='Millenium Falcon',
- faction=rebels,
- )
+ falcon = Ship(id="4", name="Millenium Falcon", faction=rebels)
falcon.save()
- homeOne = Ship(
- id='5',
- name='Home One',
- faction=rebels,
- )
+ homeOne = Ship(id="5", name="Home One", faction=rebels)
homeOne.save()
- tieFighter = Ship(
- id='6',
- name='TIE Fighter',
- faction=empire,
- )
+ tieFighter = Ship(id="6", name="TIE Fighter", faction=empire)
tieFighter.save()
- tieInterceptor = Ship(
- id='7',
- name='TIE Interceptor',
- faction=empire,
- )
+ tieInterceptor = Ship(id="7", name="TIE Interceptor", faction=empire)
tieInterceptor.save()
- executor = Ship(
- id='8',
- name='Executor',
- faction=empire,
- )
+ executor = Ship(id="8", name="Executor", faction=empire)
executor.save()
def create_ship(ship_name, faction_id):
- new_ship = Ship(
- name=ship_name,
- faction_id=faction_id
- )
+ new_ship = Ship(name=ship_name, faction_id=faction_id)
new_ship.save()
return new_ship
diff --git a/examples/starwars/models.py b/examples/starwars/models.py
index 45741da..03e06a2 100644
--- a/examples/starwars/models.py
+++ b/examples/starwars/models.py
@@ -5,7 +5,13 @@ from django.db import models
class Character(models.Model):
name = models.CharField(max_length=50)
- ship = models.ForeignKey('Ship', on_delete=models.CASCADE, blank=True, null=True, related_name='characters')
+ ship = models.ForeignKey(
+ "Ship",
+ on_delete=models.CASCADE,
+ blank=True,
+ null=True,
+ related_name="characters",
+ )
def __str__(self):
return self.name
@@ -21,7 +27,7 @@ class Faction(models.Model):
class Ship(models.Model):
name = models.CharField(max_length=50)
- faction = models.ForeignKey(Faction, on_delete=models.CASCADE, related_name='ships')
+ faction = models.ForeignKey(Faction, on_delete=models.CASCADE, related_name="ships")
def __str__(self):
return self.name
diff --git a/examples/starwars/schema.py b/examples/starwars/schema.py
index 492918e..fb22840 100644
--- a/examples/starwars/schema.py
+++ b/examples/starwars/schema.py
@@ -2,18 +2,16 @@ import graphene
from graphene import Schema, relay, resolve_only_args
from graphene_django import DjangoConnectionField, DjangoObjectType
-from .data import (create_ship, get_empire, get_faction, get_rebels, get_ship,
- get_ships)
+from .data import create_ship, get_empire, get_faction, get_rebels, get_ship, get_ships
from .models import Character as CharacterModel
from .models import Faction as FactionModel
from .models import Ship as ShipModel
class Ship(DjangoObjectType):
-
class Meta:
model = ShipModel
- interfaces = (relay.Node, )
+ interfaces = (relay.Node,)
@classmethod
def get_node(cls, info, id):
@@ -22,16 +20,14 @@ class Ship(DjangoObjectType):
class Character(DjangoObjectType):
-
class Meta:
model = CharacterModel
class Faction(DjangoObjectType):
-
class Meta:
model = FactionModel
- interfaces = (relay.Node, )
+ interfaces = (relay.Node,)
@classmethod
def get_node(cls, info, id):
@@ -39,7 +35,6 @@ class Faction(DjangoObjectType):
class IntroduceShip(relay.ClientIDMutation):
-
class Input:
ship_name = graphene.String(required=True)
faction_id = graphene.String(required=True)
@@ -48,7 +43,9 @@ class IntroduceShip(relay.ClientIDMutation):
faction = graphene.Field(Faction)
@classmethod
- def mutate_and_get_payload(cls, root, info, ship_name, faction_id, client_mutation_id=None):
+ def mutate_and_get_payload(
+ cls, root, info, ship_name, faction_id, client_mutation_id=None
+ ):
ship = create_ship(ship_name, faction_id)
faction = get_faction(faction_id)
return IntroduceShip(ship=ship, faction=faction)
@@ -58,7 +55,7 @@ class Query(graphene.ObjectType):
rebels = graphene.Field(Faction)
empire = graphene.Field(Faction)
node = relay.Node.Field()
- ships = DjangoConnectionField(Ship, description='All the ships.')
+ ships = DjangoConnectionField(Ship, description="All the ships.")
@resolve_only_args
def resolve_ships(self):
diff --git a/examples/starwars/tests/test_connections.py b/examples/starwars/tests/test_connections.py
index d266df3..425dce5 100644
--- a/examples/starwars/tests/test_connections.py
+++ b/examples/starwars/tests/test_connections.py
@@ -8,7 +8,7 @@ pytestmark = pytest.mark.django_db
def test_correct_fetch_first_ship_rebels():
initialize()
- query = '''
+ query = """
query RebelsShipsQuery {
rebels {
name,
@@ -24,22 +24,12 @@ def test_correct_fetch_first_ship_rebels():
}
}
}
- '''
+ """
expected = {
- 'rebels': {
- 'name': 'Alliance to Restore the Republic',
- 'hero': {
- 'name': 'Human'
- },
- 'ships': {
- 'edges': [
- {
- 'node': {
- 'name': 'X-Wing'
- }
- }
- ]
- }
+ "rebels": {
+ "name": "Alliance to Restore the Republic",
+ "hero": {"name": "Human"},
+ "ships": {"edges": [{"node": {"name": "X-Wing"}}]},
}
}
result = schema.execute(query)
@@ -49,7 +39,7 @@ def test_correct_fetch_first_ship_rebels():
def test_correct_list_characters():
initialize()
- query = '''
+ query = """
query RebelsShipsQuery {
node(id: "U2hpcDox") {
... on Ship {
@@ -60,15 +50,8 @@ def test_correct_list_characters():
}
}
}
- '''
- expected = {
- 'node': {
- 'name': 'X-Wing',
- 'characters': [{
- 'name': 'Human'
- }],
- }
- }
+ """
+ expected = {"node": {"name": "X-Wing", "characters": [{"name": "Human"}]}}
result = schema.execute(query)
assert not result.errors
assert result.data == expected
diff --git a/examples/starwars/tests/test_mutation.py b/examples/starwars/tests/test_mutation.py
index aa312ff..e24bf8a 100644
--- a/examples/starwars/tests/test_mutation.py
+++ b/examples/starwars/tests/test_mutation.py
@@ -9,7 +9,7 @@ pytestmark = pytest.mark.django_db
def test_mutations():
initialize()
- query = '''
+ query = """
mutation MyMutation {
introduceShip(input:{clientMutationId:"abc", shipName: "Peter", factionId: "1"}) {
ship {
@@ -29,49 +29,23 @@ def test_mutations():
}
}
}
- '''
+ """
expected = {
- 'introduceShip': {
- 'ship': {
- 'id': 'U2hpcDo5',
- 'name': 'Peter'
- },
- 'faction': {
- 'name': 'Alliance to Restore the Republic',
- 'ships': {
- 'edges': [{
- 'node': {
- 'id': 'U2hpcDox',
- 'name': 'X-Wing'
- }
- }, {
- 'node': {
- 'id': 'U2hpcDoy',
- 'name': 'Y-Wing'
- }
- }, {
- 'node': {
- 'id': 'U2hpcDoz',
- 'name': 'A-Wing'
- }
- }, {
- 'node': {
- 'id': 'U2hpcDo0',
- 'name': 'Millenium Falcon'
- }
- }, {
- 'node': {
- 'id': 'U2hpcDo1',
- 'name': 'Home One'
- }
- }, {
- 'node': {
- 'id': 'U2hpcDo5',
- 'name': 'Peter'
- }
- }]
+ "introduceShip": {
+ "ship": {"id": "U2hpcDo5", "name": "Peter"},
+ "faction": {
+ "name": "Alliance to Restore the Republic",
+ "ships": {
+ "edges": [
+ {"node": {"id": "U2hpcDox", "name": "X-Wing"}},
+ {"node": {"id": "U2hpcDoy", "name": "Y-Wing"}},
+ {"node": {"id": "U2hpcDoz", "name": "A-Wing"}},
+ {"node": {"id": "U2hpcDo0", "name": "Millenium Falcon"}},
+ {"node": {"id": "U2hpcDo1", "name": "Home One"}},
+ {"node": {"id": "U2hpcDo5", "name": "Peter"}},
+ ]
},
- }
+ },
}
}
result = schema.execute(query)
diff --git a/examples/starwars/tests/test_objectidentification.py b/examples/starwars/tests/test_objectidentification.py
index fad1958..6e04a7b 100644
--- a/examples/starwars/tests/test_objectidentification.py
+++ b/examples/starwars/tests/test_objectidentification.py
@@ -8,19 +8,16 @@ pytestmark = pytest.mark.django_db
def test_correctly_fetches_id_name_rebels():
initialize()
- query = '''
+ query = """
query RebelsQuery {
rebels {
id
name
}
}
- '''
+ """
expected = {
- 'rebels': {
- 'id': 'RmFjdGlvbjox',
- 'name': 'Alliance to Restore the Republic'
- }
+ "rebels": {"id": "RmFjdGlvbjox", "name": "Alliance to Restore the Republic"}
}
result = schema.execute(query)
assert not result.errors
@@ -29,7 +26,7 @@ def test_correctly_fetches_id_name_rebels():
def test_correctly_refetches_rebels():
initialize()
- query = '''
+ query = """
query RebelsRefetchQuery {
node(id: "RmFjdGlvbjox") {
id
@@ -38,12 +35,9 @@ def test_correctly_refetches_rebels():
}
}
}
- '''
+ """
expected = {
- 'node': {
- 'id': 'RmFjdGlvbjox',
- 'name': 'Alliance to Restore the Republic'
- }
+ "node": {"id": "RmFjdGlvbjox", "name": "Alliance to Restore the Republic"}
}
result = schema.execute(query)
assert not result.errors
@@ -52,20 +46,15 @@ def test_correctly_refetches_rebels():
def test_correctly_fetches_id_name_empire():
initialize()
- query = '''
+ query = """
query EmpireQuery {
empire {
id
name
}
}
- '''
- expected = {
- 'empire': {
- 'id': 'RmFjdGlvbjoy',
- 'name': 'Galactic Empire'
- }
- }
+ """
+ expected = {"empire": {"id": "RmFjdGlvbjoy", "name": "Galactic Empire"}}
result = schema.execute(query)
assert not result.errors
assert result.data == expected
@@ -73,7 +62,7 @@ def test_correctly_fetches_id_name_empire():
def test_correctly_refetches_empire():
initialize()
- query = '''
+ query = """
query EmpireRefetchQuery {
node(id: "RmFjdGlvbjoy") {
id
@@ -82,13 +71,8 @@ def test_correctly_refetches_empire():
}
}
}
- '''
- expected = {
- 'node': {
- 'id': 'RmFjdGlvbjoy',
- 'name': 'Galactic Empire'
- }
- }
+ """
+ expected = {"node": {"id": "RmFjdGlvbjoy", "name": "Galactic Empire"}}
result = schema.execute(query)
assert not result.errors
assert result.data == expected
@@ -96,7 +80,7 @@ def test_correctly_refetches_empire():
def test_correctly_refetches_xwing():
initialize()
- query = '''
+ query = """
query XWingRefetchQuery {
node(id: "U2hpcDox") {
id
@@ -105,13 +89,8 @@ def test_correctly_refetches_xwing():
}
}
}
- '''
- expected = {
- 'node': {
- 'id': 'U2hpcDox',
- 'name': 'X-Wing'
- }
- }
+ """
+ expected = {"node": {"id": "U2hpcDox", "name": "X-Wing"}}
result = schema.execute(query)
assert not result.errors
assert result.data == expected
diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py
index 4538cb3..38f8d8a 100644
--- a/graphene_django/__init__.py
+++ b/graphene_django/__init__.py
@@ -1,6 +1,6 @@
from .types import DjangoObjectType
from .fields import DjangoConnectionField
-__version__ = "2.2.0"
+__version__ = "2.9.1"
__all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"]
diff --git a/graphene_django/compat.py b/graphene_django/compat.py
index 4a51de8..59fab30 100644
--- a/graphene_django/compat.py
+++ b/graphene_django/compat.py
@@ -5,7 +5,11 @@ class MissingType(object):
try:
# Postgres fields are only available in Django with psycopg2 installed
# and we cannot have psycopg2 on PyPy
- from django.contrib.postgres.fields import (ArrayField, HStoreField,
- JSONField, RangeField)
+ from django.contrib.postgres.fields import (
+ ArrayField,
+ HStoreField,
+ JSONField,
+ RangeField,
+ )
except ImportError:
ArrayField, HStoreField, JSONField, RangeField = (MissingType,) * 4
diff --git a/graphene_django/converter.py b/graphene_django/converter.py
index c40313d..bd8f79d 100644
--- a/graphene_django/converter.py
+++ b/graphene_django/converter.py
@@ -1,5 +1,7 @@
+from collections import OrderedDict
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 (
ID,
@@ -21,6 +23,7 @@ from graphene.types.json import JSONString
from graphene.utils.str_converters import to_camel_case, to_const
from graphql import assert_valid_name
+from .settings import graphene_settings
from .compat import ArrayField, HStoreField, JSONField, RangeField
from .fields import DjangoListField, DjangoConnectionField
from .utils import import_single_dispatch
@@ -29,7 +32,7 @@ singledispatch = import_single_dispatch()
def convert_choice_name(name):
- name = to_const(force_text(name))
+ name = to_const(force_str(name))
try:
assert_valid_name(name)
except AssertionError:
@@ -39,6 +42,8 @@ def convert_choice_name(name):
def get_choices(choices):
converted_names = []
+ if isinstance(choices, OrderedDict):
+ choices = choices.items()
for value, help_text in choices:
if isinstance(help_text, (tuple, list)):
for choice in get_choices(help_text):
@@ -52,26 +57,56 @@ def get_choices(choices):
yield name, value, description
-def convert_django_field_with_choices(field, registry=None):
+def convert_choices_to_named_enum_with_descriptions(name, choices):
+ choices = list(get_choices(choices))
+ named_choices = [(c[0], c[1]) for c in choices]
+ named_choices_descriptions = {c[0]: c[2] for c in choices}
+
+ class EnumWithDescriptionsType(object):
+ @property
+ def description(self):
+ return named_choices_descriptions[self.name]
+
+ return Enum(name, list(named_choices), type=EnumWithDescriptionsType)
+
+
+def generate_enum_name(django_model_meta, field):
+ if graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME:
+ # Try and import custom function
+ custom_func = import_string(
+ graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME
+ )
+ name = custom_func(field)
+ elif graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING is True:
+ name = "{app_label}{object_name}{field_name}Choices".format(
+ app_label=to_camel_case(django_model_meta.app_label.title()),
+ object_name=django_model_meta.object_name,
+ field_name=to_camel_case(field.name.title()),
+ )
+ else:
+ name = to_camel_case("{}_{}".format(django_model_meta.object_name, field.name))
+ return name
+
+
+def convert_choice_field_to_enum(field, name=None):
+ if name is None:
+ name = generate_enum_name(field.model._meta, field)
+ choices = field.choices
+ return convert_choices_to_named_enum_with_descriptions(name, choices)
+
+
+def convert_django_field_with_choices(
+ field, registry=None, convert_choices_to_enum=True
+):
if registry is not None:
converted = registry.get_converted_field(field)
if converted:
return converted
choices = getattr(field, "choices", None)
- if choices:
- meta = field.model._meta
- name = to_camel_case("{}_{}".format(meta.object_name, field.name))
- choices = list(get_choices(choices))
- named_choices = [(c[0], c[1]) for c in choices]
- named_choices_descriptions = {c[0]: c[2] for c in choices}
-
- class EnumWithDescriptionsType(object):
- @property
- def description(self):
- return named_choices_descriptions[self.name]
-
- enum = Enum(name, list(named_choices), type=EnumWithDescriptionsType)
- converted = enum(description=field.help_text, required=not field.null)
+ if choices and convert_choices_to_enum:
+ enum = convert_choice_field_to_enum(field)
+ required = not (field.blank or field.null)
+ converted = enum(description=field.help_text, required=required)
else:
converted = convert_django_field(field, registry)
if registry is not None:
@@ -177,19 +212,32 @@ def convert_field_to_list_or_connection(field, registry=None):
if not _type:
return
+ description = (
+ field.help_text
+ if isinstance(field, models.ManyToManyField)
+ else field.field.help_text
+ )
+
# If there is a connection, we should transform the field
# into a DjangoConnectionField
if _type._meta.connection:
# Use a DjangoFilterConnectionField if there are
- # defined filter_fields in the DjangoObjectType Meta
- if _type._meta.filter_fields:
+ # defined filter_fields or a filterset_class in the
+ # DjangoObjectType Meta
+ if _type._meta.filter_fields or _type._meta.filterset_class:
from .filter.fields import DjangoFilterConnectionField
- return DjangoFilterConnectionField(_type)
+ return DjangoFilterConnectionField(
+ _type, required=True, description=description
+ )
- return DjangoConnectionField(_type)
+ return DjangoConnectionField(_type, required=True, description=description)
- return DjangoListField(_type)
+ return DjangoListField(
+ _type,
+ required=True, # A Set is always returned, never None.
+ description=description,
+ )
return Dynamic(dynamic_type)
@@ -219,12 +267,12 @@ def convert_postgres_array_to_list(field, registry=None):
@convert_django_field.register(HStoreField)
@convert_django_field.register(JSONField)
-def convert_posgres_field_to_string(field, registry=None):
+def convert_postgres_field_to_string(field, registry=None):
return JSONString(description=field.help_text, required=not field.null)
@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)
if not isinstance(inner_type, (List, NonNull)):
inner_type = type(inner_type)
diff --git a/graphene_django/debug/middleware.py b/graphene_django/debug/middleware.py
index 48d471f..0fe3fe3 100644
--- a/graphene_django/debug/middleware.py
+++ b/graphene_django/debug/middleware.py
@@ -16,14 +16,18 @@ class DjangoDebugContext(object):
def get_debug_promise(self):
if not self.debug_promise:
self.debug_promise = Promise.all(self.promises)
+ self.promises = []
return self.debug_promise.then(self.on_resolve_all_promises)
def on_resolve_all_promises(self, values):
+ if self.promises:
+ self.debug_promise = None
+ return self.get_debug_promise()
self.disable_instrumentation()
return self.object
def add_promise(self, promise):
- if self.debug_promise and not self.debug_promise.is_fulfilled:
+ if self.debug_promise:
self.promises.append(promise)
def enable_instrumentation(self):
diff --git a/graphene_django/debug/sql/tracking.py b/graphene_django/debug/sql/tracking.py
index f96583b..a7c9d8d 100644
--- a/graphene_django/debug/sql/tracking.py
+++ b/graphene_django/debug/sql/tracking.py
@@ -5,8 +5,8 @@ import json
from threading import local
from time import time
-from django.utils import six
-from django.utils.encoding import force_text
+import six
+from django.utils.encoding import force_str
from .types import DjangoDebugSQL
@@ -78,7 +78,7 @@ class NormalCursorWrapper(object):
def _quote_expr(self, element):
if isinstance(element, six.string_types):
- return "'%s'" % force_text(element).replace("'", "''")
+ return "'%s'" % force_str(element).replace("'", "''")
else:
return repr(element)
@@ -91,7 +91,7 @@ class NormalCursorWrapper(object):
def _decode(self, param):
try:
- return force_text(param, strings_only=True)
+ return force_str(param, strings_only=True)
except UnicodeDecodeError:
return "(encoded string)"
diff --git a/graphene_django/debug/sql/types.py b/graphene_django/debug/sql/types.py
index 850ced4..eeef482 100644
--- a/graphene_django/debug/sql/types.py
+++ b/graphene_django/debug/sql/types.py
@@ -3,9 +3,7 @@ from graphene import Boolean, Float, ObjectType, String
class DjangoDebugSQL(ObjectType):
class Meta:
- description = (
- "Represents a single database query made to a Django managed DB."
- )
+ description = "Represents a single database query made to a Django managed DB."
vendor = String(
required=True,
@@ -14,37 +12,26 @@ class DjangoDebugSQL(ObjectType):
),
)
alias = String(
- required=True,
- description="The Django database alias (e.g. 'default').",
+ required=True, description="The Django database alias (e.g. 'default')."
)
sql = String(description="The actual SQL sent to this database.")
duration = Float(
- required=True,
- description="Duration of this database query in seconds.",
+ required=True, description="Duration of this database query in seconds."
)
raw_sql = String(
- required=True,
- description="The raw SQL of this query, without params.",
+ required=True, description="The raw SQL of this query, without params."
)
params = String(
- required=True,
- description="JSON encoded database query parameters.",
- )
- start_time = Float(
- required=True,
- description="Start time of this database query.",
- )
- stop_time = Float(
- required=True,
- description="Stop time of this database query.",
+ required=True, description="JSON encoded database query parameters."
)
+ start_time = Float(required=True, description="Start time of this database query.")
+ stop_time = Float(required=True, description="Stop time of this database query.")
is_slow = Boolean(
required=True,
description="Whether this database query took more than 10 seconds.",
)
is_select = Boolean(
- required=True,
- description="Whether this database query was a SELECT.",
+ required=True, description="Whether this database query was a SELECT."
)
# Postgres
diff --git a/graphene_django/debug/tests/test_query.py b/graphene_django/debug/tests/test_query.py
index f2ef096..db8f275 100644
--- a/graphene_django/debug/tests/test_query.py
+++ b/graphene_django/debug/tests/test_query.py
@@ -31,7 +31,7 @@ def test_should_query_field():
class Query(graphene.ObjectType):
reporter = graphene.Field(ReporterType)
- debug = graphene.Field(DjangoDebug, name="__debug")
+ debug = graphene.Field(DjangoDebug, name="_debug")
def resolve_reporter(self, info, **args):
return Reporter.objects.first()
@@ -41,7 +41,7 @@ def test_should_query_field():
reporter {
lastName
}
- __debug {
+ _debug {
sql {
rawSql
}
@@ -50,9 +50,7 @@ def test_should_query_field():
"""
expected = {
"reporter": {"lastName": "ABA"},
- "__debug": {
- "sql": [{"rawSql": str(Reporter.objects.order_by("pk")[:1].query)}]
- },
+ "_debug": {"sql": [{"rawSql": str(Reporter.objects.order_by("pk")[:1].query)}]},
}
schema = graphene.Schema(query=Query)
result = schema.execute(
@@ -62,6 +60,73 @@ def test_should_query_field():
assert result.data == expected
+def test_should_query_nested_field():
+ r1 = Reporter(last_name="ABA")
+ r1.save()
+ r2 = Reporter(last_name="Griffin")
+ r2.save()
+ r2.pets.add(r1)
+ r1.pets.add(r2)
+
+ class ReporterType(DjangoObjectType):
+ class Meta:
+ model = Reporter
+ interfaces = (Node,)
+
+ class Query(graphene.ObjectType):
+ reporter = graphene.Field(ReporterType)
+ debug = graphene.Field(DjangoDebug, name="__debug")
+
+ def resolve_reporter(self, info, **args):
+ return Reporter.objects.first()
+
+ query = """
+ query ReporterQuery {
+ reporter {
+ lastName
+ pets { edges { node {
+ lastName
+ pets { edges { node { lastName } } }
+ } } }
+ }
+ __debug {
+ sql {
+ rawSql
+ }
+ }
+ }
+ """
+ expected = {
+ "reporter": {
+ "lastName": "ABA",
+ "pets": {
+ "edges": [
+ {
+ "node": {
+ "lastName": "Griffin",
+ "pets": {"edges": [{"node": {"lastName": "ABA"}}]},
+ }
+ }
+ ]
+ },
+ }
+ }
+ schema = graphene.Schema(query=Query)
+ result = schema.execute(
+ query, context_value=context(), middleware=[DjangoDebugMiddleware()]
+ )
+ assert not result.errors
+ query = str(Reporter.objects.order_by("pk")[:1].query)
+ assert result.data["__debug"]["sql"][0]["rawSql"] == query
+ assert "COUNT" in result.data["__debug"]["sql"][1]["rawSql"]
+ assert "tests_reporter_pets" in result.data["__debug"]["sql"][2]["rawSql"]
+ assert "COUNT" in result.data["__debug"]["sql"][3]["rawSql"]
+ assert "tests_reporter_pets" in result.data["__debug"]["sql"][4]["rawSql"]
+ assert len(result.data["__debug"]["sql"]) == 5
+
+ assert result.data["reporter"] == expected["reporter"]
+
+
def test_should_query_list():
r1 = Reporter(last_name="ABA")
r1.save()
@@ -75,7 +140,7 @@ def test_should_query_list():
class Query(graphene.ObjectType):
all_reporters = graphene.List(ReporterType)
- debug = graphene.Field(DjangoDebug, name="__debug")
+ debug = graphene.Field(DjangoDebug, name="_debug")
def resolve_all_reporters(self, info, **args):
return Reporter.objects.all()
@@ -85,7 +150,7 @@ def test_should_query_list():
allReporters {
lastName
}
- __debug {
+ _debug {
sql {
rawSql
}
@@ -94,7 +159,7 @@ def test_should_query_list():
"""
expected = {
"allReporters": [{"lastName": "ABA"}, {"lastName": "Griffin"}],
- "__debug": {"sql": [{"rawSql": str(Reporter.objects.all().query)}]},
+ "_debug": {"sql": [{"rawSql": str(Reporter.objects.all().query)}]},
}
schema = graphene.Schema(query=Query)
result = schema.execute(
@@ -117,7 +182,7 @@ def test_should_query_connection():
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
- debug = graphene.Field(DjangoDebug, name="__debug")
+ debug = graphene.Field(DjangoDebug, name="_debug")
def resolve_all_reporters(self, info, **args):
return Reporter.objects.all()
@@ -131,7 +196,7 @@ def test_should_query_connection():
}
}
}
- __debug {
+ _debug {
sql {
rawSql
}
@@ -145,9 +210,9 @@ def test_should_query_connection():
)
assert not result.errors
assert result.data["allReporters"] == expected["allReporters"]
- assert "COUNT" in result.data["__debug"]["sql"][0]["rawSql"]
+ assert "COUNT" in result.data["_debug"]["sql"][0]["rawSql"]
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():
@@ -166,7 +231,7 @@ def test_should_query_connectionfilter():
class Query(graphene.ObjectType):
all_reporters = DjangoFilterConnectionField(ReporterType, fields=["last_name"])
s = graphene.String(resolver=lambda *_: "S")
- debug = graphene.Field(DjangoDebug, name="__debug")
+ debug = graphene.Field(DjangoDebug, name="_debug")
def resolve_all_reporters(self, info, **args):
return Reporter.objects.all()
@@ -180,7 +245,7 @@ def test_should_query_connectionfilter():
}
}
}
- __debug {
+ _debug {
sql {
rawSql
}
@@ -194,6 +259,6 @@ def test_should_query_connectionfilter():
)
assert not result.errors
assert result.data["allReporters"] == expected["allReporters"]
- assert "COUNT" in result.data["__debug"]["sql"][0]["rawSql"]
+ assert "COUNT" in result.data["_debug"]["sql"][0]["rawSql"]
query = str(Reporter.objects.all()[:1].query)
- assert result.data["__debug"]["sql"][1]["rawSql"] == query
+ assert result.data["_debug"]["sql"][1]["rawSql"] == query
diff --git a/graphene_django/debug/types.py b/graphene_django/debug/types.py
index cda5725..1cd816d 100644
--- a/graphene_django/debug/types.py
+++ b/graphene_django/debug/types.py
@@ -7,7 +7,4 @@ class DjangoDebug(ObjectType):
class Meta:
description = "Debugging information for the current query."
- sql = List(
- DjangoDebugSQL,
- description="Executed SQL queries for this API query.",
- )
+ sql = List(DjangoDebugSQL, description="Executed SQL queries for this API query.")
diff --git a/graphene_django/fields.py b/graphene_django/fields.py
index 35df1e1..718e970 100644
--- a/graphene_django/fields.py
+++ b/graphene_django/fields.py
@@ -1,30 +1,62 @@
from functools import partial, reduce
from django.db.models.query import QuerySet
+
+from graphene import NonNull
from graphene.types import Field, List
from graphene.relay import ConnectionField, PageInfo
from graphene.utils.get_unbound_function import get_unbound_function
from graphql_relay.connection.arrayconnection import connection_from_list_slice
+from graphene_django.utils.utils import auth_resolver
from promise import Promise
from .settings import graphene_settings
-from .utils import maybe_queryset, auth_resolver
+from .utils import maybe_queryset
class DjangoListField(Field):
def __init__(self, _type, *args, **kwargs):
- super(DjangoListField, self).__init__(List(_type), *args, **kwargs)
+ from .types import DjangoObjectType
+
+ if isinstance(_type, NonNull):
+ _type = _type.of_type
+
+ # Django would never return a Set of None vvvvvvv
+ super(DjangoListField, self).__init__(List(NonNull(_type)), *args, **kwargs)
+
+ assert issubclass(
+ self._underlying_type, DjangoObjectType
+ ), "DjangoListField only accepts DjangoObjectType types"
+
+ @property
+ def _underlying_type(self):
+ _type = self._type
+ while hasattr(_type, "of_type"):
+ _type = _type.of_type
+ return _type
@property
def model(self):
- return self.type.of_type._meta.node._meta.model
+ return self._underlying_type._meta.model
@staticmethod
- def list_resolver(resolver, root, info, **args):
- return maybe_queryset(resolver(root, info, **args))
+ def list_resolver(django_object_type, resolver, root, info, **args):
+ queryset = maybe_queryset(resolver(root, info, **args))
+ if queryset is None:
+ # Default to Django Model queryset
+ # N.B. This happens if DjangoListField is used in the top level Query object
+ model_manager = django_object_type._meta.model.objects
+ queryset = maybe_queryset(
+ django_object_type.get_queryset(model_manager, info)
+ )
+ return queryset
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)
class DjangoConnectionField(ConnectionField):
@@ -44,17 +76,31 @@ class DjangoConnectionField(ConnectionField):
from .types import DjangoObjectType
_type = super(ConnectionField, self).type
+ non_null = False
+ if isinstance(_type, NonNull):
+ _type = _type.of_type
+ non_null = True
assert issubclass(
_type, DjangoObjectType
), "DjangoConnectionField only accepts DjangoObjectType types"
assert _type._meta.connection, "The type {} doesn't have a connection".format(
_type.__name__
)
- return _type._meta.connection
+ connection_type = _type._meta.connection
+ if non_null:
+ return NonNull(connection_type)
+ return connection_type
+
+ @property
+ def connection_type(self):
+ type = self.type
+ if isinstance(type, NonNull):
+ return type.of_type
+ return type
@property
def node_type(self):
- return self.type._meta.node
+ return self.connection_type._meta.node
@property
def model(self):
@@ -67,22 +113,18 @@ class DjangoConnectionField(ConnectionField):
return self.model._default_manager
@classmethod
- def merge_querysets(cls, default_queryset, queryset):
- if default_queryset.query.distinct and not queryset.query.distinct:
- queryset = queryset.distinct()
- elif queryset.query.distinct and not default_queryset.query.distinct:
- default_queryset = default_queryset.distinct()
- return queryset & default_queryset
+ def resolve_queryset(cls, connection, queryset, info, args):
+ # queryset is the resolved iterable from ObjectType
+ return connection._meta.node.get_queryset(queryset, info)
@classmethod
- def resolve_connection(cls, connection, default_manager, args, iterable):
+ def resolve_connection(cls, connection, args, default_manager, iterable):
+
if iterable is None:
iterable = default_manager
+
iterable = maybe_queryset(iterable)
if isinstance(iterable, QuerySet):
- if iterable is not default_manager:
- default_queryset = maybe_queryset(default_manager)
- iterable = cls.merge_querysets(default_queryset, iterable)
_len = iterable.count()
else:
_len = len(iterable)
@@ -106,6 +148,7 @@ class DjangoConnectionField(ConnectionField):
resolver,
connection,
default_manager,
+ queryset_resolver,
max_limit,
enforce_first_or_last,
root,
@@ -133,8 +176,15 @@ class DjangoConnectionField(ConnectionField):
).format(last, info.field_name, max_limit)
args["last"] = min(last, max_limit)
+ # eventually leads to DjangoObjectType's get_queryset (accepts queryset)
+ # or a resolve_foo (does not accept queryset)
iterable = resolver(root, info, **args)
- on_resolve = partial(cls.resolve_connection, connection, default_manager, 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, default_manager)
if Promise.is_thenable(iterable):
return Promise.resolve(iterable).then(on_resolve)
@@ -145,17 +195,23 @@ class DjangoConnectionField(ConnectionField):
return partial(
self.connection_resolver,
parent_resolver,
- self.type,
+ self.connection_type,
self.get_manager(),
+ self.get_queryset_resolver(),
self.max_limit,
self.enforce_first_or_last,
)
+ def get_queryset_resolver(self):
+ return self.resolve_queryset
+
class DjangoField(Field):
"""Class to manage permission for fields"""
- def __init__(self, type, permissions=(), permissions_resolver=auth_resolver, *args, **kwargs):
+ def __init__(
+ self, type, permissions=(), permissions_resolver=auth_resolver, *args, **kwargs
+ ):
"""Get permissions to access a field"""
super(DjangoField, self).__init__(type, *args, **kwargs)
self.permissions = permissions
@@ -165,15 +221,23 @@ class DjangoField(Field):
"""Intercept resolver to analyse permissions"""
parent_resolver = super(DjangoField, self).get_resolver(parent_resolver)
if self.permissions:
- return partial(get_unbound_function(self.permissions_resolver), parent_resolver, self.permissions, None,
- None, True)
+ return partial(
+ get_unbound_function(self.permissions_resolver),
+ parent_resolver,
+ self.permissions,
+ None,
+ None,
+ True,
+ )
return parent_resolver
class DataLoaderField(DjangoField):
"""Class to manage access to data-loader when resolve the field"""
- def __init__(self, type, data_loader, source_loader, load_many=False, *args, **kwargs):
+ def __init__(
+ self, type, data_loader, source_loader, load_many=False, *args, **kwargs
+ ):
"""
Initialization of data-loader to resolve field
:param data_loader: data-loader to resolve field
@@ -193,7 +257,9 @@ class DataLoaderField(DjangoField):
def resolver_data_loader(self, root, info, *args, **kwargs):
"""Resolve field through dataloader"""
if root:
- source_loader = reduce(lambda x, y: getattr(x, y), self.source_loader.split('.'), root)
+ source_loader = reduce(
+ lambda x, y: getattr(x, y), self.source_loader.split("."), root
+ )
else:
source_loader = kwargs.get(self.source_loader)
diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py
index cb42543..a46a4b7 100644
--- a/graphene_django/filter/fields.py
+++ b/graphene_django/filter/fields.py
@@ -40,9 +40,10 @@ class DjangoFilterConnectionField(DjangoConnectionField):
if self._extra_filter_meta:
meta.update(self._extra_filter_meta)
- self._filterset_class = get_filterset_class(
- self._provided_filterset_class, **meta
+ filterset_class = self._provided_filterset_class or (
+ self.node_type._meta.filterset_class
)
+ self._filterset_class = get_filterset_class(filterset_class, **meta)
return self._filterset_class
@@ -51,69 +52,18 @@ class DjangoFilterConnectionField(DjangoConnectionField):
return get_filtering_args_from_filterset(self.filterset_class, self.node_type)
@classmethod
- def merge_querysets(cls, default_queryset, queryset):
- # There could be the case where the default queryset (returned from the filterclass)
- # and the resolver queryset have some limits on it.
- # We only would be able to apply one of those, but not both
- # at the same time.
-
- # See related PR: https://github.com/graphql-python/graphene-django/pull/126
-
- assert not (
- default_queryset.query.low_mark and queryset.query.low_mark
- ), "Received two sliced querysets (low mark) in the connection, please slice only in one."
- assert not (
- default_queryset.query.high_mark and queryset.query.high_mark
- ), "Received two sliced querysets (high mark) in the connection, please slice only in one."
- low = default_queryset.query.low_mark or queryset.query.low_mark
- high = default_queryset.query.high_mark or queryset.query.high_mark
- default_queryset.query.clear_limits()
- queryset = super(DjangoFilterConnectionField, cls).merge_querysets(
- default_queryset, queryset
- )
- queryset.query.set_limits(low, high)
- return queryset
-
- @classmethod
- def connection_resolver(
- cls,
- resolver,
- connection,
- default_manager,
- max_limit,
- enforce_first_or_last,
- filterset_class,
- filtering_args,
- root,
- info,
- **args
+ def resolve_queryset(
+ cls, connection, iterable, info, args, filtering_args, filterset_class
):
+ qs = super(DjangoFilterConnectionField, cls).resolve_queryset(
+ connection, iterable, info, args
+ )
filter_kwargs = {k: v for k, v in args.items() if k in filtering_args}
- qs = filterset_class(
- data=filter_kwargs,
- queryset=default_manager.get_queryset(),
- request=info.context,
- ).qs
+ return filterset_class(data=filter_kwargs, queryset=qs, request=info.context).qs
- return super(DjangoFilterConnectionField, cls).connection_resolver(
- resolver,
- connection,
- qs,
- max_limit,
- enforce_first_or_last,
- root,
- info,
- **args
- )
-
- def get_resolver(self, parent_resolver):
+ def get_queryset_resolver(self):
return partial(
- self.connection_resolver,
- parent_resolver,
- self.type,
- self.get_manager(),
- self.max_limit,
- self.enforce_first_or_last,
- self.filterset_class,
- self.filtering_args,
+ self.resolve_queryset,
+ filterset_class=self.filterset_class,
+ filtering_args=self.filtering_args,
)
diff --git a/graphene_django/filter/filterset.py b/graphene_django/filter/filterset.py
index 4059083..7676ea8 100644
--- a/graphene_django/filter/filterset.py
+++ b/graphene_django/filter/filterset.py
@@ -45,8 +45,7 @@ class GrapheneFilterSetMixin(BaseFilterSet):
FILTER_DEFAULTS = dict(
itertools.chain(
- FILTER_FOR_DBFIELD_DEFAULTS.items(),
- GRAPHENE_FILTER_SET_OVERRIDES.items()
+ FILTER_FOR_DBFIELD_DEFAULTS.items(), GRAPHENE_FILTER_SET_OVERRIDES.items()
)
)
@@ -59,7 +58,6 @@ if VERSION[0] < 2:
from django.utils.text import capfirst
class GrapheneFilterSetMixinPython2(GrapheneFilterSetMixin):
-
@classmethod
def filter_for_reverse_field(cls, f, name):
"""Handles retrieving filters for reverse relationships
diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py
index f9ef0ae..a0f7d96 100644
--- a/graphene_django/filter/tests/test_fields.py
+++ b/graphene_django/filter/tests/test_fields.py
@@ -1,18 +1,17 @@
from datetime import datetime
+from textwrap import dedent
import pytest
+from django.db.models import TextField, Value
+from django.db.models.functions import Concat
-from graphene import Field, ObjectType, Schema, Argument, Float, Boolean, String
+from graphene import Argument, Boolean, Field, Float, ObjectType, Schema, String
from graphene.relay import Node
from graphene_django import DjangoObjectType
from graphene_django.forms import GlobalIDFormField, GlobalIDMultipleChoiceField
from graphene_django.tests.models import Article, Pet, Reporter
from graphene_django.utils import DJANGO_FILTER_INSTALLED
-# for annotation test
-from django.db.models import TextField, Value
-from django.db.models.functions import Concat
-
pytestmark = []
if DJANGO_FILTER_INSTALLED:
@@ -57,8 +56,6 @@ if DJANGO_FILTER_INSTALLED:
model = Pet
interfaces = (Node,)
- # schema = Schema()
-
def get_args(field):
return field.args
@@ -227,6 +224,74 @@ def test_filter_filterset_information_on_meta_related():
assert_not_orderable(articles_field)
+def test_filter_filterset_class_filter_fields_exception():
+ with pytest.raises(Exception):
+
+ class ReporterFilter(FilterSet):
+ class Meta:
+ model = Reporter
+ fields = ["first_name", "articles"]
+
+ class ReporterFilterNode(DjangoObjectType):
+ class Meta:
+ model = Reporter
+ interfaces = (Node,)
+ filterset_class = ReporterFilter
+ filter_fields = ["first_name", "articles"]
+
+
+def test_filter_filterset_class_information_on_meta():
+ class ReporterFilter(FilterSet):
+ class Meta:
+ model = Reporter
+ fields = ["first_name", "articles"]
+
+ class ReporterFilterNode(DjangoObjectType):
+ class Meta:
+ model = Reporter
+ interfaces = (Node,)
+ filterset_class = ReporterFilter
+
+ field = DjangoFilterConnectionField(ReporterFilterNode)
+ assert_arguments(field, "first_name", "articles")
+ assert_not_orderable(field)
+
+
+def test_filter_filterset_class_information_on_meta_related():
+ class ReporterFilter(FilterSet):
+ class Meta:
+ model = Reporter
+ fields = ["first_name", "articles"]
+
+ class ArticleFilter(FilterSet):
+ class Meta:
+ model = Article
+ fields = ["headline", "reporter"]
+
+ class ReporterFilterNode(DjangoObjectType):
+ class Meta:
+ model = Reporter
+ interfaces = (Node,)
+ filterset_class = ReporterFilter
+
+ class ArticleFilterNode(DjangoObjectType):
+ class Meta:
+ model = Article
+ interfaces = (Node,)
+ filterset_class = ArticleFilter
+
+ class Query(ObjectType):
+ all_reporters = DjangoFilterConnectionField(ReporterFilterNode)
+ all_articles = DjangoFilterConnectionField(ArticleFilterNode)
+ reporter = Field(ReporterFilterNode)
+ article = Field(ArticleFilterNode)
+
+ schema = Schema(query=Query)
+ articles_field = ReporterFilterNode._meta.fields["articles"].get_type()
+ assert_arguments(articles_field, "headline", "reporter")
+ assert_not_orderable(articles_field)
+
+
def test_filter_filterset_related_results():
class ReporterFilterNode(DjangoObjectType):
class Meta:
@@ -253,12 +318,14 @@ def test_filter_filterset_related_results():
pub_date=datetime.now(),
pub_date_time=datetime.now(),
reporter=r1,
+ editor=r1,
)
Article.objects.create(
headline="a2",
pub_date=datetime.now(),
pub_date_time=datetime.now(),
reporter=r2,
+ editor=r2,
)
query = """
@@ -382,7 +449,7 @@ def test_global_id_multiple_field_explicit_reverse():
assert multiple_filter.field_class == GlobalIDMultipleChoiceField
-def test_filter_filterset_related_results():
+def test_filter_filterset_related_results_with_filter():
class ReporterFilterNode(DjangoObjectType):
class Meta:
model = Reporter
@@ -392,15 +459,15 @@ def test_filter_filterset_related_results():
class Query(ObjectType):
all_reporters = DjangoFilterConnectionField(ReporterFilterNode)
- r1 = Reporter.objects.create(
+ Reporter.objects.create(
first_name="A test user", last_name="Last Name", email="test1@test.com"
)
- r2 = Reporter.objects.create(
+ Reporter.objects.create(
first_name="Other test user",
last_name="Other Last Name",
email="test2@test.com",
)
- r3 = Reporter.objects.create(
+ Reporter.objects.create(
first_name="Random", last_name="RandomLast", email="random@test.com"
)
@@ -541,58 +608,6 @@ def test_should_query_filter_node_limit():
assert result.data == expected
-def test_should_query_filter_node_double_limit_raises():
- class ReporterFilter(FilterSet):
- limit = NumberFilter(method="filter_limit")
-
- def filter_limit(self, queryset, name, value):
- return queryset[:value]
-
- class Meta:
- model = Reporter
- fields = ["first_name"]
-
- class ReporterType(DjangoObjectType):
- class Meta:
- model = Reporter
- interfaces = (Node,)
-
- class Query(ObjectType):
- all_reporters = DjangoFilterConnectionField(
- ReporterType, filterset_class=ReporterFilter
- )
-
- def resolve_all_reporters(self, info, **args):
- return Reporter.objects.order_by("a_choice")[:2]
-
- Reporter.objects.create(
- first_name="Bob", last_name="Doe", email="bobdoe@example.com", a_choice=2
- )
- r = Reporter.objects.create(
- first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
- )
-
- schema = Schema(query=Query)
- query = """
- query NodeFilteringQuery {
- allReporters(limit: 1) {
- edges {
- node {
- id
- firstName
- }
- }
- }
- }
- """
-
- result = schema.execute(query)
- assert len(result.errors) == 1
- assert str(result.errors[0]) == (
- "Received two sliced querysets (high mark) in the connection, please slice only in one."
- )
-
-
def test_order_by_is_perserved():
class ReporterType(DjangoObjectType):
class Meta:
@@ -614,7 +629,7 @@ def test_order_by_is_perserved():
return reporters
Reporter.objects.create(first_name="b")
- r = Reporter.objects.create(first_name="a")
+ Reporter.objects.create(first_name="a")
schema = Schema(query=Query)
query = """
@@ -654,7 +669,7 @@ def test_order_by_is_perserved():
assert reverse_result.data == reverse_expected
-def test_annotation_is_perserved():
+def test_annotation_is_preserved():
class ReporterType(DjangoObjectType):
full_name = String()
@@ -697,3 +712,290 @@ def test_annotation_is_perserved():
assert not result.errors
assert result.data == expected
+
+
+def test_annotation_with_only():
+ class ReporterType(DjangoObjectType):
+ full_name = String()
+
+ class Meta:
+ model = Reporter
+ interfaces = (Node,)
+ filter_fields = ()
+
+ class Query(ObjectType):
+ all_reporters = DjangoFilterConnectionField(ReporterType)
+
+ def resolve_all_reporters(self, info, **args):
+ return Reporter.objects.only("first_name", "last_name").annotate(
+ full_name=Concat(
+ "first_name", Value(" "), "last_name", output_field=TextField()
+ )
+ )
+
+ Reporter.objects.create(first_name="John", last_name="Doe")
+
+ schema = Schema(query=Query)
+
+ query = """
+ query NodeFilteringQuery {
+ allReporters(first: 1) {
+ edges {
+ node {
+ fullName
+ }
+ }
+ }
+ }
+ """
+ expected = {"allReporters": {"edges": [{"node": {"fullName": "John Doe"}}]}}
+
+ result = schema.execute(query)
+
+ assert not result.errors
+ assert result.data == expected
+
+
+def test_node_get_queryset_is_called():
+ class ReporterType(DjangoObjectType):
+ class Meta:
+ model = Reporter
+ interfaces = (Node,)
+ filter_fields = ()
+
+ @classmethod
+ def get_queryset(cls, queryset, info):
+ return queryset.filter(first_name="b")
+
+ class Query(ObjectType):
+ all_reporters = DjangoFilterConnectionField(
+ ReporterType, reverse_order=Boolean()
+ )
+
+ Reporter.objects.create(first_name="b")
+ Reporter.objects.create(first_name="a")
+
+ schema = Schema(query=Query)
+ query = """
+ query NodeFilteringQuery {
+ allReporters(first: 10) {
+ edges {
+ node {
+ firstName
+ }
+ }
+ }
+ }
+ """
+ expected = {"allReporters": {"edges": [{"node": {"firstName": "b"}}]}}
+
+ result = schema.execute(query)
+ assert not result.errors
+ assert result.data == expected
+
+
+def test_integer_field_filter_type():
+ class PetType(DjangoObjectType):
+ class Meta:
+ model = Pet
+ interfaces = (Node,)
+ filter_fields = {"age": ["exact"]}
+ fields = ("age",)
+
+ class Query(ObjectType):
+ pets = DjangoFilterConnectionField(PetType)
+
+ schema = Schema(query=Query)
+
+ assert str(schema) == dedent(
+ """\
+ schema {
+ query: Query
+ }
+
+ interface Node {
+ id: ID!
+ }
+
+ type PageInfo {
+ hasNextPage: Boolean!
+ hasPreviousPage: Boolean!
+ startCursor: String
+ endCursor: String
+ }
+
+ type PetType implements Node {
+ age: Int!
+ id: ID!
+ }
+
+ type PetTypeConnection {
+ pageInfo: PageInfo!
+ edges: [PetTypeEdge]!
+ }
+
+ type PetTypeEdge {
+ node: PetType
+ cursor: String!
+ }
+
+ type Query {
+ pets(before: String, after: String, first: Int, last: Int, age: Int): PetTypeConnection
+ }
+ """
+ )
+
+
+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():
+ class ArticleFilterMixin(FilterSet):
+ @classmethod
+ def get_filters(cls):
+ filters = super(FilterSet, cls).get_filters()
+ filters.update(
+ {
+ "viewer__email__in": django_filters.CharFilter(
+ method="filter_email_in", field_name="reporter__email__in"
+ )
+ }
+ )
+
+ return filters
+
+ def filter_email_in(cls, queryset, name, value):
+ return queryset.filter(**{name: [value]})
+
+ class NewArticleFilter(ArticleFilterMixin, ArticleFilter):
+ pass
+
+ class NewReporterNode(DjangoObjectType):
+ class Meta:
+ model = Reporter
+ interfaces = (Node,)
+
+ class NewArticleFilterNode(DjangoObjectType):
+ viewer = Field(NewReporterNode)
+
+ class Meta:
+ model = Article
+ interfaces = (Node,)
+ filterset_class = NewArticleFilter
+
+ def resolve_viewer(self, info):
+ return self.reporter
+
+ class Query(ObjectType):
+ all_articles = DjangoFilterConnectionField(NewArticleFilterNode)
+
+ reporter_1 = Reporter.objects.create(
+ first_name="John", last_name="Doe", email="john@doe.com"
+ )
+
+ article_1 = Article.objects.create(
+ headline="Hello",
+ reporter=reporter_1,
+ editor=reporter_1,
+ pub_date=datetime.now(),
+ pub_date_time=datetime.now(),
+ )
+
+ reporter_2 = Reporter.objects.create(
+ first_name="Adam", last_name="Doe", email="adam@doe.com"
+ )
+
+ article_2 = Article.objects.create(
+ headline="Good Bye",
+ reporter=reporter_2,
+ editor=reporter_2,
+ pub_date=datetime.now(),
+ pub_date_time=datetime.now(),
+ )
+
+ schema = Schema(query=Query)
+
+ query = (
+ """
+ query NodeFilteringQuery {
+ allArticles(viewer_Email_In: "%s") {
+ edges {
+ node {
+ headline
+ viewer {
+ email
+ }
+ }
+ }
+ }
+ }
+ """
+ % reporter_1.email
+ )
+
+ expected = {
+ "allArticles": {
+ "edges": [
+ {
+ "node": {
+ "headline": article_1.headline,
+ "viewer": {"email": reporter_1.email},
+ }
+ }
+ ]
+ }
+ }
+
+ result = schema.execute(query)
+
+ assert not result.errors
+ assert result.data == expected
diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py
index cfa5621..c5f18e2 100644
--- a/graphene_django/filter/utils.py
+++ b/graphene_django/filter/utils.py
@@ -1,5 +1,6 @@
import six
+from django_filters.utils import get_model_field
from .filterset import custom_filterset_factory, setup_filterset
@@ -11,8 +12,26 @@ def get_filtering_args_from_filterset(filterset_class, type):
from ..forms.converter import convert_form_field
args = {}
+ model = filterset_class._meta.model
for name, filter_field in six.iteritems(filterset_class.base_filters):
- field_type = convert_form_field(filter_field.field).Argument()
+ form_field = None
+
+ if name in filterset_class.declared_filters:
+ form_field = filter_field.field
+ else:
+ model_field = get_model_field(model, filter_field.field_name)
+ filter_type = filter_field.lookup_expr
+ if filter_type != "isnull" and hasattr(model_field, "formfield"):
+ form_field = model_field.formfield(
+ required=filter_field.extra.get("required", False)
+ )
+
+ # Fallback to field defined on filter if we can't get it from the
+ # model field
+ if not form_field:
+ form_field = filter_field.field
+
+ field_type = convert_form_field(form_field).Argument()
field_type.description = filter_field.label
args[name] = field_type
diff --git a/graphene_django/forms/converter.py b/graphene_django/forms/converter.py
index 023eca7..9fc5b8d 100644
--- a/graphene_django/forms/converter.py
+++ b/graphene_django/forms/converter.py
@@ -1,14 +1,25 @@
from django import forms
from django.core.exceptions import ImproperlyConfigured
-from graphene import ID, Boolean, Float, Int, List, String, UUID, Date, DateTime, Time, Enum
+from graphene import (
+ ID,
+ Boolean,
+ Float,
+ Int,
+ List,
+ String,
+ UUID,
+ Date,
+ DateTime,
+ Time,
+ Enum,
+)
from graphene.utils.str_converters import to_camel_case
from graphene_django.converter import get_choices
from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField
from ..utils import import_single_dispatch
-
singledispatch = import_single_dispatch()
@@ -45,7 +56,7 @@ def convert_form_field_to_int(field):
@convert_form_field.register(forms.BooleanField)
def convert_form_field_to_boolean(field):
- return Boolean(description=field.help_text, required=True)
+ return Boolean(description=field.help_text, required=field.required)
@convert_form_field.register(forms.NullBooleanField)
@@ -88,7 +99,7 @@ def convert_form_field_to_id(field):
def get_form_name(form):
"""Get form name"""
- class_name = str(form.__class__).split('.')[-1]
+ class_name = str(form.__class__).split(".")[-1]
return class_name[:-2]
@@ -100,15 +111,20 @@ def convert_form_field_with_choices(field, name=None, form=None):
:param form: field's form
:return: graphene Field
"""
- choices = getattr(field, 'choices', None)
+ choices = getattr(field, "choices", None)
# If is a choice field, but not depends on models
- if not isinstance(field, (forms.ModelMultipleChoiceField, forms.ModelChoiceField)) and choices:
+ if (
+ not isinstance(field, (forms.ModelMultipleChoiceField, forms.ModelChoiceField))
+ and choices
+ ):
if form:
- name = to_camel_case("{}_{}".format(get_form_name(form), field.label or name))
+ name = to_camel_case(
+ "{}_{}".format(get_form_name(form), field.label or name)
+ )
else:
name = field.label or name
- name = to_camel_case(name.replace(' ', '_'))
+ name = to_camel_case(name.replace(" ", "_"))
choices = list(get_choices(choices))
named_choices = [(c[0], c[1]) for c in choices]
named_choices_descriptions = {c[0]: c[2] for c in choices}
@@ -123,5 +139,7 @@ def convert_form_field_with_choices(field, name=None, form=None):
return named_choices_descriptions[self.name]
enum = Enum(name, list(named_choices), type=EnumWithDescriptionsType)
- return enum(description=field.help_text, required=field.required) # pylint: disable=E1102
+ return enum(
+ description=field.help_text, required=field.required
+ ) # pylint: disable=E1102
return convert_form_field(field)
diff --git a/graphene_django/forms/forms.py b/graphene_django/forms/forms.py
index 14e68c8..4b81859 100644
--- a/graphene_django/forms/forms.py
+++ b/graphene_django/forms/forms.py
@@ -2,7 +2,7 @@ import binascii
from django.core.exceptions import ValidationError
from django.forms import CharField, Field, MultipleChoiceField
-from django.utils.translation import ugettext_lazy as _
+from django.utils.translation import gettext_lazy as _
from graphql_relay import from_global_id
diff --git a/graphene_django/forms/mutation.py b/graphene_django/forms/mutation.py
index 8efa120..32c40b3 100644
--- a/graphene_django/forms/mutation.py
+++ b/graphene_django/forms/mutation.py
@@ -10,11 +10,10 @@ from graphene.types.mutation import MutationOptions
# InputObjectType,
# )
from graphene.types.utils import yank_fields_from_attrs
-from graphene.utils.str_converters import to_camel_case
from graphene_django.registry import get_global_registry
+from ..types import ErrorType
from .converter import convert_form_field_with_choices
-from .types import ErrorType
def fields_for_form(form, only_fields, exclude_fields):
@@ -45,10 +44,7 @@ class BaseDjangoFormMutation(ClientIDMutation):
if form.is_valid():
return cls.perform_mutate(form, info)
else:
- errors = [
- ErrorType(field=to_camel_case(key) if key != '__all__' else key, messages=value)
- for key, value in form.errors.items()
- ]
+ errors = ErrorType.from_errors(form.errors)
return cls(errors=errors)
@@ -69,28 +65,6 @@ class BaseDjangoFormMutation(ClientIDMutation):
return kwargs
-# class DjangoFormInputObjectTypeOptions(InputObjectTypeOptions):
-# form_class = None
-
-
-# class DjangoFormInputObjectType(InputObjectType):
-# class Meta:
-# abstract = True
-
-# @classmethod
-# def __init_subclass_with_meta__(cls, form_class=None,
-# only_fields=(), exclude_fields=(), _meta=None, **options):
-# if not _meta:
-# _meta = DjangoFormInputObjectTypeOptions(cls)
-# assert isinstance(form_class, forms.Form), (
-# 'form_class must be an instance of django.forms.Form'
-# )
-# _meta.form_class = form_class
-# form = form_class()
-# fields = fields_for_form(form, only_fields, exclude_fields)
-# super(DjangoFormInputObjectType, cls).__init_subclass_with_meta__(_meta=_meta, fields=fields, **options)
-
-
class DjangoFormMutationOptions(MutationOptions):
form_class = None
@@ -103,7 +77,12 @@ class DjangoFormMutation(BaseDjangoFormMutation):
@classmethod
def __init_subclass_with_meta__(
- cls, form_class=None, mirror_input=False, only_fields=(), exclude_fields=(), **options
+ cls,
+ form_class=None,
+ mirror_input=False,
+ only_fields=(),
+ exclude_fields=(),
+ **options
):
if not form_class:
@@ -128,7 +107,7 @@ class DjangoFormMutation(BaseDjangoFormMutation):
@classmethod
def perform_mutate(cls, form, info):
form.save()
- return cls(errors=[])
+ return cls(errors=[], **form.cleaned_data)
class DjangoModelDjangoFormMutationOptions(DjangoFormMutationOptions):
@@ -169,7 +148,9 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
registry = get_global_registry()
model_type = registry.get_type_for_model(model)
- return_field_name = return_field_name
+ if not model_type:
+ raise Exception("No type registered for model: {}".format(model.__name__))
+
if not return_field_name:
model_name = model.__name__
return_field_name = model_name[:1].lower() + model_name[1:]
diff --git a/graphene_django/forms/tests/test_converter.py b/graphene_django/forms/tests/test_converter.py
index e76c5c2..b6ee2b7 100644
--- a/graphene_django/forms/tests/test_converter.py
+++ b/graphene_django/forms/tests/test_converter.py
@@ -1,7 +1,6 @@
from django import forms
from py.test import raises
-import graphene
from graphene import (
String,
Int,
@@ -116,6 +115,8 @@ def test_should_manytoone_convert_connectionorlist():
def test_should_typed_choice_convert_enum():
- field = forms.TypedChoiceField(choices=(('A', 'Choice A'), ('B', 'Choice B')), label='field')
- graphene_type = convert_form_field_with_choices(field, name='field_name')
+ field = forms.TypedChoiceField(
+ choices=(("A", "Choice A"), ("B", "Choice B")), label="field"
+ )
+ graphene_type = convert_form_field_with_choices(field, name="field_name")
assert isinstance(graphene_type, Enum)
diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py
index 5d28259..e6a7542 100644
--- a/graphene_django/forms/tests/test_mutation.py
+++ b/graphene_django/forms/tests/test_mutation.py
@@ -1,21 +1,54 @@
from django import forms
from django.test import TestCase
+from django.core.exceptions import ValidationError
from py.test import raises
-from graphene_django.tests.models import Pet, Film, FilmDetails
+from graphene import ObjectType, Schema, String, Field
+from graphene_django import DjangoObjectType
+from graphene_django.tests.models import Film, Pet
+
+from ...settings import graphene_settings
from ..mutation import DjangoFormMutation, DjangoModelFormMutation
class MyForm(forms.Form):
- text = forms.CharField()
+ text = forms.CharField(required=False)
+
+ def clean_text(self):
+ text = self.cleaned_data["text"]
+ if text == "INVALID_INPUT":
+ raise ValidationError("Invalid input")
+ return text
+
+ def save(self):
+ pass
class PetForm(forms.ModelForm):
class Meta:
model = Pet
- fields = '__all__'
+ fields = "__all__"
+
test_camel = forms.IntegerField(required=False)
+ def clean_age(self):
+ age = self.cleaned_data["age"]
+ if age >= 99:
+ raise ValidationError("Too old")
+ return age
+
+
+class PetType(DjangoObjectType):
+ class Meta:
+ model = Pet
+ fields = "__all__"
+
+
+class FilmType(DjangoObjectType):
+ class Meta:
+ model = Film
+ fields = "__all__"
+
def test_needs_form_class():
with raises(Exception) as exc:
@@ -42,6 +75,118 @@ def test_has_input_fields():
assert "text" in MyMutation.Input._meta.fields
+def test_mutation_error_camelcased():
+ class ExtraPetForm(PetForm):
+ test_field = forms.CharField(required=True)
+
+ class PetMutation(DjangoModelFormMutation):
+ class Meta:
+ form_class = ExtraPetForm
+
+ result = PetMutation.mutate_and_get_payload(None, None)
+ assert {f.field for f in result.errors} == {"name", "age", "test_field"}
+ graphene_settings.CAMELCASE_ERRORS = True
+ result = PetMutation.mutate_and_get_payload(None, None)
+ assert {f.field for f in result.errors} == {"name", "age", "testField"}
+ graphene_settings.CAMELCASE_ERRORS = False
+
+
+class MockQuery(ObjectType):
+ a = String()
+
+
+class FormMutationTests(TestCase):
+ def test_form_invalid_form(self):
+ class MyMutation(DjangoFormMutation):
+ class Meta:
+ form_class = MyForm
+ mirror_input = True
+
+ 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
+ }
+ }
+ """
+ )
+
+ self.assertIs(result.errors, None)
+ self.assertEqual(
+ result.data["myMutation"]["errors"],
+ [{"field": "text", "messages": ["Invalid input"]}],
+ )
+
+ def test_form_valid_input(self):
+ class MyMutation(DjangoFormMutation):
+ class Meta:
+ form_class = MyForm
+ mirror_input = True
+
+ 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
+ }
+ }
+ """
+ )
+
+ self.assertIs(result.errors, None)
+ self.assertEqual(result.data["myMutation"]["errors"], [])
+ self.assertEqual(result.data["myMutation"]["text"], "VALID_INPUT")
+
+ def test_default_meta_fields(self):
+ class MyMutation(DjangoFormMutation):
+ class Meta:
+ form_class = MyForm
+
+ self.assertNotIn("text", MyMutation._meta.fields)
+
+ def test_mirror_meta_fields(self):
+ class MyMutation(DjangoFormMutation):
+ class Meta:
+ form_class = MyForm
+ mirror_input = True
+
+ self.assertIn("text", MyMutation._meta.fields)
+
+ def test_default_input_meta_fields(self):
+ class MyMutation(DjangoFormMutation):
+ class Meta:
+ form_class = MyForm
+
+ self.assertIn("client_mutation_id", MyMutation.Input._meta.fields)
+ self.assertIn("text", MyMutation.Input._meta.fields)
+
+ def test_exclude_fields_input_meta_fields(self):
+ class MyMutation(DjangoFormMutation):
+ class Meta:
+ form_class = MyForm
+ exclude_fields = ["text"]
+
+ self.assertNotIn("text", MyMutation.Input._meta.fields)
+ self.assertIn("client_mutation_id", MyMutation.Input._meta.fields)
+
+
class ModelFormMutationTests(TestCase):
def test_default_meta_fields(self):
class PetMutation(DjangoModelFormMutation):
@@ -67,7 +212,7 @@ class ModelFormMutationTests(TestCase):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
- exclude_fields = ['id']
+ exclude_fields = ["id"]
self.assertEqual(PetMutation._meta.model, Pet)
self.assertEqual(PetMutation._meta.return_field_name, "pet")
@@ -76,101 +221,137 @@ class ModelFormMutationTests(TestCase):
self.assertIn("client_mutation_id", PetMutation.Input._meta.fields)
self.assertNotIn("id", PetMutation.Input._meta.fields)
- def test_return_field_name_is_camelcased(self):
- class PetMutation(DjangoModelFormMutation):
- class Meta:
- form_class = PetForm
- model = FilmDetails
-
- self.assertEqual(PetMutation._meta.model, FilmDetails)
- self.assertEqual(PetMutation._meta.return_field_name, "filmDetails")
-
def test_custom_return_field_name(self):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
- model = Film
+ model = Pet
return_field_name = "animal"
- self.assertEqual(PetMutation._meta.model, Film)
+ self.assertEqual(PetMutation._meta.model, Pet)
self.assertEqual(PetMutation._meta.return_field_name, "animal")
self.assertIn("animal", PetMutation._meta.fields)
- def test_model_form_mutation_mutate(self):
+ def test_model_form_mutation_mutate_existing(self):
class PetMutation(DjangoModelFormMutation):
+ pet = Field(PetType)
+
class Meta:
form_class = PetForm
+ class Mutation(ObjectType):
+ pet_mutation = PetMutation.Field()
+
+ schema = Schema(query=MockQuery, mutation=Mutation)
+
pet = Pet.objects.create(name="Axel", age=10)
- result = PetMutation.mutate_and_get_payload(None, None, id=pet.pk, name="Mia", age=10)
+ result = schema.execute(
+ """ mutation PetMutation($pk: ID!) {
+ petMutation(input: { id: $pk, name: "Mia", age: 10 }) {
+ pet {
+ name
+ age
+ }
+ }
+ }
+ """,
+ variable_values={"pk": pet.pk},
+ )
+
+ self.assertIs(result.errors, None)
+ self.assertEqual(result.data["petMutation"]["pet"], {"name": "Mia", "age": 10})
self.assertEqual(Pet.objects.count(), 1)
pet.refresh_from_db()
self.assertEqual(pet.name, "Mia")
- self.assertEqual(result.errors, [])
- def test_model_form_mutation_updates_existing_(self):
+ def test_model_form_mutation_creates_new(self):
class PetMutation(DjangoModelFormMutation):
+ pet = Field(PetType)
+
class Meta:
form_class = PetForm
- result = PetMutation.mutate_and_get_payload(None, None, name="Mia", age=10)
+ class Mutation(ObjectType):
+ pet_mutation = PetMutation.Field()
+
+ schema = Schema(query=MockQuery, mutation=Mutation)
+
+ result = schema.execute(
+ """ mutation PetMutation {
+ petMutation(input: { name: "Mia", age: 10 }) {
+ pet {
+ name
+ age
+ }
+ errors {
+ field
+ messages
+ }
+ }
+ }
+ """
+ )
+ self.assertIs(result.errors, None)
+ self.assertEqual(result.data["petMutation"]["pet"], {"name": "Mia", "age": 10})
self.assertEqual(Pet.objects.count(), 1)
pet = Pet.objects.get()
self.assertEqual(pet.name, "Mia")
self.assertEqual(pet.age, 10)
- self.assertEqual(result.errors, [])
+
+ def test_model_form_mutation_invalid_input(self):
+ class PetMutation(DjangoModelFormMutation):
+ pet = Field(PetType)
+
+ class Meta:
+ form_class = PetForm
+
+ class Mutation(ObjectType):
+ pet_mutation = PetMutation.Field()
+
+ schema = Schema(query=MockQuery, mutation=Mutation)
+
+ result = schema.execute(
+ """ mutation PetMutation {
+ petMutation(input: { name: "Mia", age: 99 }) {
+ pet {
+ name
+ age
+ }
+ errors {
+ field
+ messages
+ }
+ }
+ }
+ """
+ )
+ self.assertIs(result.errors, None)
+ self.assertEqual(result.data["petMutation"]["pet"], None)
+ self.assertEqual(
+ result.data["petMutation"]["errors"],
+ [{"field": "age", "messages": ["Too old"],}],
+ )
+
+ self.assertEqual(Pet.objects.count(), 0)
def test_model_form_mutation_mutate_invalid_form(self):
class PetMutation(DjangoModelFormMutation):
class Meta:
form_class = PetForm
- result = PetMutation.mutate_and_get_payload(None, None, test_camel='text')
+ result = PetMutation.mutate_and_get_payload(None, None, test_camel="text")
# A pet was not created
self.assertEqual(Pet.objects.count(), 0)
fields_w_error = {e.field: e.messages for e in result.errors}
self.assertEqual(len(result.errors), 3)
- self.assertIn("testCamel", fields_w_error)
- self.assertEqual(fields_w_error['testCamel'], ["Enter a whole number."])
+ self.assertIn("test_camel", fields_w_error)
+ self.assertEqual(fields_w_error["test_camel"], ["Enter a whole number."])
self.assertIn("name", fields_w_error)
- self.assertEqual(fields_w_error['name'], ["This field is required."])
+ self.assertEqual(fields_w_error["name"], ["This field is required."])
self.assertIn("age", fields_w_error)
- self.assertEqual(fields_w_error['age'], ["This field is required."])
-
-
-class FormMutationTests(TestCase):
- def test_default_meta_fields(self):
- class MyMutation(DjangoFormMutation):
- class Meta:
- form_class = MyForm
- self.assertNotIn("text", MyMutation._meta.fields)
-
- def test_mirror_meta_fields(self):
- class MyMutation(DjangoFormMutation):
- class Meta:
- form_class = MyForm
- mirror_input = True
-
- self.assertIn("text", MyMutation._meta.fields)
-
- def test_default_input_meta_fields(self):
- class MyMutation(DjangoFormMutation):
- class Meta:
- form_class = MyForm
-
- self.assertIn("client_mutation_id", MyMutation.Input._meta.fields)
- self.assertIn("text", MyMutation.Input._meta.fields)
-
- def test_exclude_fields_input_meta_fields(self):
- class MyMutation(DjangoFormMutation):
- class Meta:
- form_class = MyForm
- exclude_fields = ['text']
-
- self.assertNotIn("text", MyMutation.Input._meta.fields)
- self.assertIn("client_mutation_id", MyMutation.Input._meta.fields)
+ self.assertEqual(fields_w_error["age"], ["This field is required."])
diff --git a/graphene_django/forms/types.py b/graphene_django/forms/types.py
index 1fe33f3..5005040 100644
--- a/graphene_django/forms/types.py
+++ b/graphene_django/forms/types.py
@@ -1,6 +1,3 @@
import graphene
-
-class ErrorType(graphene.ObjectType):
- field = graphene.String()
- messages = graphene.List(graphene.String)
+from ..types import ErrorType # noqa Import ErrorType for backwards compatability
diff --git a/graphene_django/management/commands/graphql_schema.py b/graphene_django/management/commands/graphql_schema.py
index 4e526ec..751a385 100644
--- a/graphene_django/management/commands/graphql_schema.py
+++ b/graphene_django/management/commands/graphql_schema.py
@@ -1,8 +1,12 @@
+import os
import importlib
import json
+import functools
from django.core.management.base import BaseCommand, CommandError
+from django.utils import autoreload
+from graphql import print_schema
from graphene_django.settings import graphene_settings
@@ -32,14 +36,50 @@ class CommandArguments(BaseCommand):
help="Output file indent (default: None)",
)
+ parser.add_argument(
+ "--watch",
+ dest="watch",
+ default=False,
+ action="store_true",
+ help="Updates the schema on file changes (default: False)",
+ )
+
class Command(CommandArguments):
- help = "Dump Graphene schema JSON to file"
+ help = "Dump Graphene schema as a JSON or GraphQL file"
can_import_settings = True
- def save_file(self, out, schema_dict, indent):
+ def save_json_file(self, out, schema_dict, indent):
with open(out, "w") as outfile:
- json.dump(schema_dict, outfile, indent=indent)
+ json.dump(schema_dict, outfile, indent=indent, sort_keys=True)
+
+ def save_graphql_file(self, out, schema):
+ with open(out, "w") as outfile:
+ outfile.write(print_schema(schema))
+
+ def get_schema(self, schema, out, indent):
+ schema_dict = {"data": schema.introspect()}
+ if out == "-":
+ self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True))
+ else:
+ # Determine format
+ _, file_extension = os.path.splitext(out)
+
+ if file_extension == ".graphql":
+ self.save_graphql_file(out, schema)
+ elif file_extension == ".json":
+ self.save_json_file(out, schema_dict, indent)
+ else:
+ raise CommandError(
+ 'Unrecognised file format "{}"'.format(file_extension)
+ )
+
+ style = getattr(self, "style", None)
+ success = getattr(style, "SUCCESS", lambda x: x)
+
+ self.stdout.write(
+ success("Successfully dumped GraphQL schema to {}".format(out))
+ )
def handle(self, *args, **options):
options_schema = options.get("schema")
@@ -63,13 +103,10 @@ class Command(CommandArguments):
)
indent = options.get("indent")
- schema_dict = {"data": schema.introspect()}
- if out == '-':
- self.stdout.write(json.dumps(schema_dict, indent=indent))
+ watch = options.get("watch")
+ if watch:
+ autoreload.run_with_reloader(
+ functools.partial(self.get_schema, schema, out, indent)
+ )
else:
- self.save_file(out, schema_dict, indent)
-
- style = getattr(self, "style", None)
- success = getattr(style, "SUCCESS", lambda x: x)
-
- self.stdout.write(success("Successfully dumped GraphQL schema to %s" % out))
+ self.get_schema(schema, out, indent)
diff --git a/graphene_django/rest_framework/models.py b/graphene_django/rest_framework/models.py
index 848837b..bd84ce5 100644
--- a/graphene_django/rest_framework/models.py
+++ b/graphene_django/rest_framework/models.py
@@ -4,3 +4,13 @@ from django.db import models
class MyFakeModel(models.Model):
cool_name = models.CharField(max_length=50)
created = models.DateTimeField(auto_now_add=True)
+
+
+class MyFakeModelWithPassword(models.Model):
+ cool_name = models.CharField(max_length=50)
+ password = models.CharField(max_length=50)
+
+
+class MyFakeModelWithDate(models.Model):
+ cool_name = models.CharField(max_length=50)
+ last_edited = models.DateField()
diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py
index 5e343aa..592f8b3 100644
--- a/graphene_django/rest_framework/mutation.py
+++ b/graphene_django/rest_framework/mutation.py
@@ -1,15 +1,16 @@
from collections import OrderedDict
from django.shortcuts import get_object_or_404
+from rest_framework import serializers
import graphene
+from graphene.relay.mutation import ClientIDMutation
from graphene.types import Field, InputField
from graphene.types.mutation import MutationOptions
-from graphene.relay.mutation import ClientIDMutation
from graphene.types.objecttype import yank_fields_from_attrs
+from ..types import ErrorType
from .serializer_converter import convert_serializer_field
-from .types import ErrorType
class SerializerMutationOptions(MutationOptions):
@@ -19,20 +20,31 @@ class SerializerMutationOptions(MutationOptions):
serializer_class = None
-def fields_for_serializer(serializer, only_fields, exclude_fields, is_input=False):
+def fields_for_serializer(
+ serializer,
+ only_fields,
+ exclude_fields,
+ is_input=False,
+ convert_choices_to_enum=True,
+):
fields = OrderedDict()
for name, field in serializer.fields.items():
is_not_in_only = only_fields and name not in only_fields
- is_excluded = (
- name
- in exclude_fields # or
- # name in already_created_fields
+ is_excluded = any(
+ [
+ name in exclude_fields,
+ field.write_only
+ and not is_input, # don't show write_only fields in Query
+ field.read_only and is_input, # don't show read_only fields in Input
+ ]
)
if is_not_in_only or is_excluded:
continue
- fields[name] = convert_serializer_field(field, is_input=is_input)
+ fields[name] = convert_serializer_field(
+ field, is_input=is_input, convert_choices_to_enum=convert_choices_to_enum
+ )
return fields
@@ -50,9 +62,10 @@ class SerializerMutation(ClientIDMutation):
lookup_field=None,
serializer_class=None,
model_class=None,
- model_operations=["create", "update"],
+ model_operations=("create", "update"),
only_fields=(),
exclude_fields=(),
+ convert_choices_to_enum=True,
**options
):
@@ -72,10 +85,18 @@ class SerializerMutation(ClientIDMutation):
lookup_field = model_class._meta.pk.name
input_fields = fields_for_serializer(
- serializer, only_fields, exclude_fields, is_input=True
+ serializer,
+ only_fields,
+ exclude_fields,
+ is_input=True,
+ convert_choices_to_enum=convert_choices_to_enum,
)
output_fields = fields_for_serializer(
- serializer, only_fields, exclude_fields, is_input=False
+ serializer,
+ only_fields,
+ exclude_fields,
+ is_input=False,
+ convert_choices_to_enum=convert_choices_to_enum,
)
_meta = SerializerMutationOptions(cls)
@@ -100,8 +121,10 @@ class SerializerMutation(ClientIDMutation):
instance = get_object_or_404(
model_class, **{lookup_field: input[lookup_field]}
)
+ partial = True
elif "create" in cls._meta.model_operations:
instance = None
+ partial = False
else:
raise Exception(
'Invalid update operation. Input parameter "{}" required.'.format(
@@ -113,6 +136,7 @@ class SerializerMutation(ClientIDMutation):
"instance": instance,
"data": input,
"context": {"request": info.context},
+ "partial": partial,
}
return {"data": input, "context": {"request": info.context}}
@@ -125,10 +149,7 @@ class SerializerMutation(ClientIDMutation):
if serializer.is_valid():
return cls.perform_mutate(serializer, info)
else:
- errors = [
- ErrorType(field=key, messages=value)
- for key, value in serializer.errors.items()
- ]
+ errors = ErrorType.from_errors(serializer.errors)
return cls(errors=errors)
@@ -138,6 +159,10 @@ class SerializerMutation(ClientIDMutation):
kwargs = {}
for f, field in serializer.fields.items():
- kwargs[f] = field.get_attribute(obj)
+ if not field.write_only:
+ if isinstance(field, serializers.SerializerMethodField):
+ kwargs[f] = field.to_representation(obj)
+ else:
+ kwargs[f] = field.get_attribute(obj)
return cls(errors=None, **kwargs)
diff --git a/graphene_django/rest_framework/serializer_converter.py b/graphene_django/rest_framework/serializer_converter.py
index 9f8e516..82a113a 100644
--- a/graphene_django/rest_framework/serializer_converter.py
+++ b/graphene_django/rest_framework/serializer_converter.py
@@ -4,6 +4,7 @@ from rest_framework import serializers
import graphene
from ..registry import get_global_registry
+from ..converter import convert_choices_to_named_enum_with_descriptions
from ..utils import import_single_dispatch
from .types import DictType
@@ -18,14 +19,17 @@ def get_graphene_type_from_serializer_field(field):
)
-def convert_serializer_field(field, is_input=True):
+def convert_serializer_field(field, is_input=True, convert_choices_to_enum=True):
"""
Converts a django rest frameworks field to a graphql field
and marks the field as required if we are creating an input type
and the field itself is required
"""
- graphql_type = get_graphene_type_from_serializer_field(field)
+ if isinstance(field, serializers.ChoiceField) and not convert_choices_to_enum:
+ graphql_type = graphene.String
+ else:
+ graphql_type = get_graphene_type_from_serializer_field(field)
args = []
kwargs = {"description": field.help_text, "required": is_input and field.required}
@@ -57,18 +61,27 @@ def convert_serializer_field(field, is_input=True):
def convert_serializer_to_input_type(serializer_class):
+ cached_type = convert_serializer_to_input_type.cache.get(
+ serializer_class.__name__, None
+ )
+ if cached_type:
+ return cached_type
serializer = serializer_class()
items = {
name: convert_serializer_field(field)
for name, field in serializer.fields.items()
}
-
- return type(
+ ret_type = type(
"{}Input".format(serializer.__class__.__name__),
(graphene.InputObjectType,),
items,
)
+ convert_serializer_to_input_type.cache[serializer_class.__name__] = ret_type
+ return ret_type
+
+
+convert_serializer_to_input_type.cache = {}
@get_graphene_type_from_serializer_field.register(serializers.Field)
@@ -121,7 +134,6 @@ def convert_serializer_field_to_time(field):
@get_graphene_type_from_serializer_field.register(serializers.ListField)
def convert_serializer_field_to_list(field, is_input=True):
child_type = get_graphene_type_from_serializer_field(field.child)
-
return (graphene.List, child_type)
@@ -136,5 +148,13 @@ def convert_serializer_field_to_jsonstring(field):
@get_graphene_type_from_serializer_field.register(serializers.MultipleChoiceField)
-def convert_serializer_field_to_list_of_string(field):
- return (graphene.List, graphene.String)
+def convert_serializer_field_to_list_of_enum(field):
+ 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)
diff --git a/graphene_django/rest_framework/tests/test_field_converter.py b/graphene_django/rest_framework/tests/test_field_converter.py
index 6fa4ca8..daa8349 100644
--- a/graphene_django/rest_framework/tests/test_field_converter.py
+++ b/graphene_django/rest_framework/tests/test_field_converter.py
@@ -10,7 +10,9 @@ from ..serializer_converter import convert_serializer_field
from ..types import DictType
-def _get_type(rest_framework_field, is_input=True, **kwargs):
+def _get_type(
+ rest_framework_field, is_input=True, convert_choices_to_enum=True, **kwargs
+):
# prevents the following error:
# AssertionError: The `source` argument is not meaningful when applied to a `child=` field.
# Remove `source=` from the field declaration.
@@ -21,7 +23,9 @@ def _get_type(rest_framework_field, is_input=True, **kwargs):
field = rest_framework_field(**kwargs)
- return convert_serializer_field(field, is_input=is_input)
+ return convert_serializer_field(
+ field, is_input=is_input, convert_choices_to_enum=convert_choices_to_enum
+ )
def assert_conversion(rest_framework_field, graphene_field, **kwargs):
@@ -60,8 +64,27 @@ def test_should_url_convert_string():
assert_conversion(serializers.URLField, graphene.String)
-def test_should_choice_convert_string():
- assert_conversion(serializers.ChoiceField, graphene.String, choices=[])
+def test_should_choice_convert_enum():
+ 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():
@@ -174,7 +197,7 @@ def test_should_file_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():
@@ -189,9 +212,9 @@ def test_should_json_convert_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(
serializers.MultipleChoiceField, graphene.List, choices=[1, 2, 3]
)
- assert field.of_type == graphene.String
+ assert issubclass(field.of_type, graphene.Enum)
diff --git a/graphene_django/rest_framework/tests/test_multiple_model_serializers.py b/graphene_django/rest_framework/tests/test_multiple_model_serializers.py
new file mode 100644
index 0000000..c1f4626
--- /dev/null
+++ b/graphene_django/rest_framework/tests/test_multiple_model_serializers.py
@@ -0,0 +1,67 @@
+import graphene
+import pytest
+from django.db import models
+from graphene import Schema
+from rest_framework import serializers
+
+from graphene_django import DjangoObjectType
+from graphene_django.rest_framework.mutation import SerializerMutation
+
+pytestmark = pytest.mark.django_db
+
+
+class MyFakeChildModel(models.Model):
+ name = models.CharField(max_length=50)
+ created = models.DateTimeField(auto_now_add=True)
+
+
+class MyFakeParentModel(models.Model):
+ name = models.CharField(max_length=50)
+ created = models.DateTimeField(auto_now_add=True)
+ child1 = models.OneToOneField(
+ MyFakeChildModel, related_name="parent1", on_delete=models.CASCADE
+ )
+ child2 = models.OneToOneField(
+ MyFakeChildModel, related_name="parent2", on_delete=models.CASCADE
+ )
+
+
+class ParentType(DjangoObjectType):
+ class Meta:
+ model = MyFakeParentModel
+ interfaces = (graphene.relay.Node,)
+
+
+class ChildType(DjangoObjectType):
+ class Meta:
+ model = MyFakeChildModel
+ interfaces = (graphene.relay.Node,)
+
+
+class MyModelChildSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = MyFakeChildModel
+ fields = "__all__"
+
+
+class MyModelParentSerializer(serializers.ModelSerializer):
+ child1 = MyModelChildSerializer()
+ child2 = MyModelChildSerializer()
+
+ class Meta:
+ model = MyFakeParentModel
+ fields = "__all__"
+
+
+class MyParentModelMutation(SerializerMutation):
+ class Meta:
+ serializer_class = MyModelParentSerializer
+
+
+class Mutation(graphene.ObjectType):
+ createParentWithChild = MyParentModelMutation.Field()
+
+
+def test_create_schema():
+ schema = Schema(mutation=Mutation, types=[ParentType, ChildType])
+ assert schema
diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py
index 4dccc18..5bf3bc1 100644
--- a/graphene_django/rest_framework/tests/test_mutation.py
+++ b/graphene_django/rest_framework/tests/test_mutation.py
@@ -1,13 +1,14 @@
import datetime
+from py.test import mark, raises
+from rest_framework import serializers
+
from graphene import Field, ResolveInfo
from graphene.types.inputobjecttype import InputObjectType
-from py.test import raises
-from py.test import mark
-from rest_framework import serializers
+from ...settings import graphene_settings
from ...types import DjangoObjectType
-from ..models import MyFakeModel
+from ..models import MyFakeModel, MyFakeModelWithPassword, MyFakeModelWithDate
from ..mutation import SerializerMutation
@@ -32,6 +33,18 @@ class MyModelSerializer(serializers.ModelSerializer):
fields = "__all__"
+class MyModelSerializerWithMethod(serializers.ModelSerializer):
+ days_since_last_edit = serializers.SerializerMethodField()
+
+ class Meta:
+ model = MyFakeModelWithDate
+ fields = "__all__"
+
+ def get_days_since_last_edit(self, obj):
+ now = datetime.date(2020, 1, 8)
+ return (now - obj.last_edited).days
+
+
class MyModelMutation(SerializerMutation):
class Meta:
serializer_class = MyModelSerializer
@@ -86,6 +99,70 @@ def test_exclude_fields():
assert "created" not in MyMutation.Input._meta.fields
+@mark.django_db
+def test_write_only_field():
+ class WriteOnlyFieldModelSerializer(serializers.ModelSerializer):
+ password = serializers.CharField(write_only=True)
+
+ class Meta:
+ model = MyFakeModelWithPassword
+ fields = ["cool_name", "password"]
+
+ class MyMutation(SerializerMutation):
+ class Meta:
+ serializer_class = WriteOnlyFieldModelSerializer
+
+ result = MyMutation.mutate_and_get_payload(
+ None, mock_info(), **{"cool_name": "New Narf", "password": "admin"}
+ )
+
+ assert hasattr(result, "cool_name")
+ assert not hasattr(
+ result, "password"
+ ), "'password' is write_only field and shouldn't be visible"
+
+
+@mark.django_db
+def test_write_only_field_using_extra_kwargs():
+ class WriteOnlyFieldModelSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = MyFakeModelWithPassword
+ fields = ["cool_name", "password"]
+ extra_kwargs = {"password": {"write_only": True}}
+
+ class MyMutation(SerializerMutation):
+ class Meta:
+ serializer_class = WriteOnlyFieldModelSerializer
+
+ result = MyMutation.mutate_and_get_payload(
+ None, mock_info(), **{"cool_name": "New Narf", "password": "admin"}
+ )
+
+ assert hasattr(result, "cool_name")
+ assert not hasattr(
+ result, "password"
+ ), "'password' is write_only field and shouldn't be visible"
+
+
+@mark.django_db
+def test_read_only_fields():
+ class ReadOnlyFieldModelSerializer(serializers.ModelSerializer):
+ cool_name = serializers.CharField(read_only=True)
+
+ class Meta:
+ model = MyFakeModelWithPassword
+ fields = ["cool_name", "password"]
+
+ class MyMutation(SerializerMutation):
+ class Meta:
+ serializer_class = ReadOnlyFieldModelSerializer
+
+ assert "password" in MyMutation.Input._meta.fields
+ assert (
+ "cool_name" not in MyMutation.Input._meta.fields
+ ), "'cool_name' is read_only field and shouldn't be on arguments"
+
+
def test_nested_model():
class MyFakeModelGrapheneType(DjangoObjectType):
class Meta:
@@ -137,6 +214,16 @@ def test_model_update_mutate_and_get_payload_success():
assert result.cool_name == "New Narf"
+@mark.django_db
+def test_model_partial_update_mutate_and_get_payload_success():
+ instance = MyFakeModel.objects.create(cool_name="Narf")
+ result = MyModelMutation.mutate_and_get_payload(
+ None, mock_info(), **{"id": instance.id}
+ )
+ assert result.errors is None
+ assert result.cool_name == "Narf"
+
+
@mark.django_db
def test_model_invalid_update_mutate_and_get_payload_success():
class InvalidModelMutation(SerializerMutation):
@@ -152,6 +239,23 @@ def test_model_invalid_update_mutate_and_get_payload_success():
assert '"id" required' in str(exc.value)
+@mark.django_db
+def test_perform_mutate_success():
+ class MyMethodMutation(SerializerMutation):
+ class Meta:
+ serializer_class = MyModelSerializerWithMethod
+
+ result = MyMethodMutation.mutate_and_get_payload(
+ None,
+ mock_info(),
+ **{"cool_name": "Narf", "last_edited": datetime.date(2020, 1, 4)}
+ )
+
+ assert result.errors is None
+ assert result.cool_name == "Narf"
+ assert result.days_since_last_edit == 4
+
+
def test_mutate_and_get_payload_error():
class MyMutation(SerializerMutation):
class Meta:
@@ -168,6 +272,13 @@ def test_model_mutate_and_get_payload_error():
assert len(result.errors) > 0
+def test_mutation_error_camelcased():
+ graphene_settings.CAMELCASE_ERRORS = True
+ result = MyModelMutation.mutate_and_get_payload(None, mock_info(), **{})
+ assert result.errors[0].field == "coolName"
+ graphene_settings.CAMELCASE_ERRORS = False
+
+
def test_invalid_serializer_operations():
with raises(Exception) as exc:
diff --git a/graphene_django/rest_framework/types.py b/graphene_django/rest_framework/types.py
index 4c84c69..2472c32 100644
--- a/graphene_django/rest_framework/types.py
+++ b/graphene_django/rest_framework/types.py
@@ -2,11 +2,6 @@ import graphene
from graphene.types.unmountedtype import UnmountedType
-class ErrorType(graphene.ObjectType):
- field = graphene.String(required=True)
- messages = graphene.List(graphene.NonNull(graphene.String), required=True)
-
-
class DictType(UnmountedType):
key = graphene.String()
value = graphene.String()
diff --git a/graphene_django/settings.py b/graphene_django/settings.py
index 7cd750a..666ad8a 100644
--- a/graphene_django/settings.py
+++ b/graphene_django/settings.py
@@ -13,9 +13,9 @@ back to the defaults.
"""
from __future__ import unicode_literals
+import six
from django.conf import settings
from django.test.signals import setting_changed
-from django.utils import six
try:
import importlib # Available in Python 3.1+
@@ -28,13 +28,17 @@ except ImportError:
DEFAULTS = {
"SCHEMA": None,
"SCHEMA_OUTPUT": "schema.json",
- "SCHEMA_INDENT": None,
+ "SCHEMA_INDENT": 2,
"MIDDLEWARE": (),
# Set to True if the connection fields must have
# either the first or last argument
"RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST": False,
# Max items returned in ConnectionFields / FilterConnectionFields
"RELAY_CONNECTION_MAX_LIMIT": 100,
+ "CAMELCASE_ERRORS": False,
+ # Set to True to enable v3 naming convention for choice field Enum's
+ "DJANGO_CHOICE_FIELD_ENUM_V3_NAMING": False,
+ "DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME": None,
}
if settings.DEBUG:
diff --git a/graphene_django/static/graphene_django/graphiql.js b/graphene_django/static/graphene_django/graphiql.js
new file mode 100644
index 0000000..c939216
--- /dev/null
+++ b/graphene_django/static/graphene_django/graphiql.js
@@ -0,0 +1,102 @@
+(function() {
+
+ // Parse the cookie value for a CSRF token
+ var csrftoken;
+ var cookies = ('; ' + document.cookie).split('; csrftoken=');
+ if (cookies.length == 2) {
+ csrftoken = cookies.pop().split(';').shift();
+ } else {
+ csrftoken = document.querySelector("[name=csrfmiddlewaretoken]").value;
+ }
+
+ // Collect the URL parameters
+ var parameters = {};
+ window.location.hash.substr(1).split('&').forEach(function (entry) {
+ var eq = entry.indexOf('=');
+ if (eq >= 0) {
+ parameters[decodeURIComponent(entry.slice(0, eq))] =
+ decodeURIComponent(entry.slice(eq + 1));
+ }
+ });
+ // Produce a Location fragment string from a parameter object.
+ function locationQuery(params) {
+ return '#' + Object.keys(params).map(function (key) {
+ return encodeURIComponent(key) + '=' +
+ encodeURIComponent(params[key]);
+ }).join('&');
+ }
+ // Derive a fetch URL from the current URL, sans the GraphQL parameters.
+ var graphqlParamNames = {
+ query: true,
+ variables: true,
+ operationName: true
+ };
+ var otherParams = {};
+ for (var k in parameters) {
+ if (parameters.hasOwnProperty(k) && graphqlParamNames[k] !== true) {
+ otherParams[k] = parameters[k];
+ }
+ }
+
+ var fetchURL = locationQuery(otherParams);
+
+ // Defines a GraphQL fetcher using the fetch API.
+ function graphQLFetcher(graphQLParams) {
+ var headers = {
+ 'Accept': 'application/json',
+ 'Content-Type': 'application/json'
+ };
+ if (csrftoken) {
+ headers['X-CSRFToken'] = csrftoken;
+ }
+ return fetch(fetchURL, {
+ method: 'post',
+ headers: headers,
+ body: JSON.stringify(graphQLParams),
+ credentials: 'include',
+ }).then(function (response) {
+ return response.text();
+ }).then(function (responseBody) {
+ try {
+ return JSON.parse(responseBody);
+ } catch (error) {
+ return responseBody;
+ }
+ });
+ }
+ // When the query and variables string is edited, update the URL bar so
+ // that it can be easily shared.
+ function onEditQuery(newQuery) {
+ parameters.query = newQuery;
+ updateURL();
+ }
+ function onEditVariables(newVariables) {
+ parameters.variables = newVariables;
+ updateURL();
+ }
+ function onEditOperationName(newOperationName) {
+ parameters.operationName = newOperationName;
+ updateURL();
+ }
+ function updateURL() {
+ history.replaceState(null, null, locationQuery(parameters));
+ }
+ var options = {
+ fetcher: graphQLFetcher,
+ onEditQuery: onEditQuery,
+ onEditVariables: onEditVariables,
+ onEditOperationName: onEditOperationName,
+ query: parameters.query,
+ }
+ if (parameters.variables) {
+ options.variables = parameters.variables;
+ }
+ if (parameters.operation_name) {
+ options.operationName = parameters.operation_name;
+ }
+ // Render into the body.
+ ReactDOM.render(
+ React.createElement(GraphiQL, options),
+ document.getElementById("editor")
+ );
+})();
diff --git a/graphene_django/templates/graphene/graphiql.html b/graphene_django/templates/graphene/graphiql.html
index 1ba0613..d0546bd 100644
--- a/graphene_django/templates/graphene/graphiql.html
+++ b/graphene_django/templates/graphene/graphiql.html
@@ -5,119 +5,34 @@ exploring GraphQL.
If you wish to receive JSON, provide the header "Accept: application/json" or
add "&raw" to the end of the URL within a browser.
-->
+{% load static %}
-
-
-
-
-
+
+
+
+
+
-
+
+ {% csrf_token %}
+
diff --git a/graphene_django/tests/issues/__init__.py b/graphene_django/tests/issues/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/graphene_django/tests/issues/test_520.py b/graphene_django/tests/issues/test_520.py
new file mode 100644
index 0000000..60c5b54
--- /dev/null
+++ b/graphene_django/tests/issues/test_520.py
@@ -0,0 +1,44 @@
+# https://github.com/graphql-python/graphene-django/issues/520
+
+import datetime
+
+from django import forms
+
+import graphene
+
+from graphene import Field, ResolveInfo
+from graphene.types.inputobjecttype import InputObjectType
+from py.test import raises
+from py.test import mark
+from rest_framework import serializers
+
+from ...types import DjangoObjectType
+from ...rest_framework.models import MyFakeModel
+from ...rest_framework.mutation import SerializerMutation
+from ...forms.mutation import DjangoFormMutation
+
+
+class MyModelSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = MyFakeModel
+ fields = "__all__"
+
+
+class MyForm(forms.Form):
+ text = forms.CharField()
+
+
+def test_can_use_form_and_serializer_mutations():
+ class MyMutation(SerializerMutation):
+ class Meta:
+ serializer_class = MyModelSerializer
+
+ class MyFormMutation(DjangoFormMutation):
+ class Meta:
+ form_class = MyForm
+
+ class Mutation(graphene.ObjectType):
+ my_mutation = MyMutation.Field()
+ my_form_mutation = MyFormMutation.Field()
+
+ graphene.Schema(mutation=Mutation)
diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py
index 4fe546d..44a5d8a 100644
--- a/graphene_django/tests/models.py
+++ b/graphene_django/tests/models.py
@@ -38,7 +38,7 @@ class Reporter(models.Model):
last_name = models.CharField(max_length=30)
email = models.EmailField()
pets = models.ManyToManyField("self")
- a_choice = models.CharField(max_length=30, choices=CHOICES)
+ a_choice = models.CharField(max_length=30, choices=CHOICES, blank=True)
objects = models.Manager()
doe_objects = DoeReporterManager()
@@ -64,6 +64,14 @@ class Reporter(models.Model):
if self.reporter_type == 2: # quick and dirty way without enums
self.__class__ = CNNReporter
+ def some_method(self):
+ return 123
+
+
+class CNNReporterManager(models.Manager):
+ def get_queryset(self):
+ return super(CNNReporterManager, self).get_queryset().filter(reporter_type=2)
+
class CNNReporter(Reporter):
"""
@@ -74,6 +82,8 @@ class CNNReporter(Reporter):
class Meta:
proxy = True
+ objects = CNNReporterManager()
+
class Article(models.Model):
headline = models.CharField(max_length=100)
diff --git a/graphene_django/tests/test_command.py b/graphene_django/tests/test_command.py
index ff6e6e1..8b0a8e6 100644
--- a/graphene_django/tests/test_command.py
+++ b/graphene_django/tests/test_command.py
@@ -1,10 +1,62 @@
+from textwrap import dedent
+
from django.core import management
-from mock import patch
+from mock import mock_open, patch
from six import StringIO
+from graphene import ObjectType, Schema, String
-@patch("graphene_django.management.commands.graphql_schema.Command.save_file")
-def test_generate_file_on_call_graphql_schema(savefile_mock, settings):
+
+@patch("graphene_django.management.commands.graphql_schema.Command.save_json_file")
+def test_generate_json_file_on_call_graphql_schema(savefile_mock, settings):
out = StringIO()
management.call_command("graphql_schema", schema="", stdout=out)
assert "Successfully dumped GraphQL schema to schema.json" in out.getvalue()
+
+
+@patch("json.dump")
+def test_json_files_are_canonical(dump_mock):
+ open_mock = mock_open()
+ with patch("graphene_django.management.commands.graphql_schema.open", open_mock):
+ management.call_command("graphql_schema", schema="")
+
+ open_mock.assert_called_once()
+
+ dump_mock.assert_called_once()
+ assert dump_mock.call_args[1][
+ "sort_keys"
+ ], "json.mock() should be used to sort the output"
+ assert (
+ dump_mock.call_args[1]["indent"] > 0
+ ), "output should be pretty-printed by default"
+
+
+def test_generate_graphql_file_on_call_graphql_schema():
+ class Query(ObjectType):
+ hi = String()
+
+ mock_schema = Schema(query=Query)
+
+ open_mock = mock_open()
+ with patch("graphene_django.management.commands.graphql_schema.open", open_mock):
+ management.call_command(
+ "graphql_schema", schema=mock_schema, out="schema.graphql"
+ )
+
+ open_mock.assert_called_once()
+
+ handle = open_mock()
+ assert handle.write.called_once()
+
+ schema_output = handle.write.call_args[0][0]
+ assert schema_output == dedent(
+ """\
+ schema {
+ query: Query
+ }
+
+ type Query {
+ hi: String
+ }
+ """
+ )
diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py
index 5dc0184..7f84de3 100644
--- a/graphene_django/tests/test_converter.py
+++ b/graphene_django/tests/test_converter.py
@@ -1,6 +1,8 @@
import pytest
+from collections import namedtuple
from django.db import models
from django.utils.translation import ugettext_lazy as _
+from graphene import NonNull
from py.test import raises
import graphene
@@ -9,9 +11,14 @@ from graphene.types.datetime import DateTime, Date, Time
from graphene.types.json import JSONString
from ..compat import JSONField, ArrayField, HStoreField, RangeField, MissingType
-from ..converter import convert_django_field, convert_django_field_with_choices
+from ..converter import (
+ convert_django_field,
+ convert_django_field_with_choices,
+ generate_enum_name,
+)
from ..registry import Registry
from ..types import DjangoObjectType
+from ..settings import graphene_settings
from .models import Article, Film, FilmDetails, Reporter
@@ -83,7 +90,7 @@ def test_should_image_convert_string():
assert_conversion(models.ImageField, graphene.String)
-def test_should_url_convert_string():
+def test_should_file_path_field_convert_string():
assert_conversion(models.FilePathField, graphene.String)
@@ -91,7 +98,7 @@ def test_should_auto_convert_id():
assert_conversion(models.AutoField, graphene.ID, primary_key=True)
-def test_should_auto_convert_id():
+def test_should_uuid_convert_id():
assert_conversion(models.UUIDField, graphene.UUID)
@@ -196,6 +203,23 @@ def test_field_with_choices_collision():
convert_django_field_with_choices(field)
+def test_field_with_choices_convert_enum_false():
+ field = models.CharField(
+ help_text="Language", choices=(("es", "Spanish"), ("en", "English"))
+ )
+
+ class TranslatedModel(models.Model):
+ language = field
+
+ class Meta:
+ app_label = "test"
+
+ graphene_type = convert_django_field_with_choices(
+ field, convert_choices_to_enum=False
+ )
+ assert isinstance(graphene_type, graphene.String)
+
+
def test_should_float_convert_float():
assert_conversion(models.FloatField, graphene.Float)
@@ -217,8 +241,12 @@ def test_should_manytomany_convert_connectionorlist_list():
assert isinstance(graphene_field, graphene.Dynamic)
dynamic_field = graphene_field.get_type()
assert isinstance(dynamic_field, graphene.Field)
- assert isinstance(dynamic_field.type, graphene.List)
- assert dynamic_field.type.of_type == A
+ # A NonNull List of NonNull A ([A!]!)
+ # https://github.com/graphql-python/graphene-django/issues/448
+ assert isinstance(dynamic_field.type, NonNull)
+ assert isinstance(dynamic_field.type.of_type, graphene.List)
+ assert isinstance(dynamic_field.type.of_type.of_type, NonNull)
+ assert dynamic_field.type.of_type.of_type.of_type == A
def test_should_manytomany_convert_connectionorlist_connection():
@@ -233,7 +261,7 @@ def test_should_manytomany_convert_connectionorlist_connection():
assert isinstance(graphene_field, graphene.Dynamic)
dynamic_field = graphene_field.get_type()
assert isinstance(dynamic_field, ConnectionField)
- assert dynamic_field.type == A._meta.connection
+ assert dynamic_field.type.of_type == A._meta.connection
def test_should_manytoone_convert_connectionorlist():
@@ -241,13 +269,15 @@ def test_should_manytoone_convert_connectionorlist():
class Meta:
model = Article
- graphene_field = convert_django_field(Reporter.articles.rel,
- A._meta.registry)
+ graphene_field = convert_django_field(Reporter.articles.rel, A._meta.registry)
assert isinstance(graphene_field, graphene.Dynamic)
dynamic_field = graphene_field.get_type()
assert isinstance(dynamic_field, graphene.Field)
- assert isinstance(dynamic_field.type, graphene.List)
- assert dynamic_field.type.of_type == A
+ # a NonNull List of NonNull A ([A!]!)
+ assert isinstance(dynamic_field.type, NonNull)
+ assert isinstance(dynamic_field.type.of_type, graphene.List)
+ assert isinstance(dynamic_field.type.of_type.of_type, NonNull)
+ assert dynamic_field.type.of_type.of_type.of_type == A
def test_should_onetoone_reverse_convert_model():
@@ -255,8 +285,7 @@ def test_should_onetoone_reverse_convert_model():
class Meta:
model = FilmDetails
- graphene_field = convert_django_field(Film.details.related,
- A._meta.registry)
+ graphene_field = convert_django_field(Film.details.related, A._meta.registry)
assert isinstance(graphene_field, graphene.Dynamic)
dynamic_field = graphene_field.get_type()
assert isinstance(dynamic_field, graphene.Field)
@@ -302,3 +331,25 @@ def test_should_postgres_range_convert_list():
assert isinstance(field.type, graphene.NonNull)
assert isinstance(field.type.of_type, graphene.List)
assert field.type.of_type.of_type == graphene.Int
+
+
+def test_generate_enum_name():
+ MockDjangoModelMeta = namedtuple("DjangoMeta", ["app_label", "object_name"])
+ graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = True
+
+ # Simple case
+ field = graphene.Field(graphene.String, name="type")
+ model_meta = MockDjangoModelMeta(app_label="users", object_name="User")
+ assert generate_enum_name(model_meta, field) == "UsersUserTypeChoices"
+
+ # More complicated multiple work case
+ field = graphene.Field(graphene.String, name="fizz_buzz")
+ model_meta = MockDjangoModelMeta(
+ app_label="some_long_app_name", object_name="SomeObject"
+ )
+ assert (
+ generate_enum_name(model_meta, field)
+ == "SomeLongAppNameSomeObjectFizzBuzzChoices"
+ )
+
+ graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = False
diff --git a/graphene_django/tests/test_fields.py b/graphene_django/tests/test_fields.py
index 0978110..4abaf15 100644
--- a/graphene_django/tests/test_fields.py
+++ b/graphene_django/tests/test_fields.py
@@ -1,9 +1,213 @@
+import datetime
+
+import pytest
+
+from graphene import List, NonNull, ObjectType, Schema, String
from mock import mock
from unittest import TestCase
from django.core.exceptions import PermissionDenied
from graphene_django.fields import DjangoField, DataLoaderField
from promise.dataloader import DataLoader
from promise import Promise
+from ..fields import DjangoListField
+from ..types import DjangoObjectType
+from .models import Article as ArticleModel
+from .models import Reporter as ReporterModel
+
+
+@pytest.mark.django_db
+class TestDjangoListField:
+ def test_only_django_object_types(self):
+ class TestType(ObjectType):
+ 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,
+ )
+
+ result = schema.execute(query)
+
+ assert not result.errors
+ assert result.data == {
+ "reporters": [
+ {"firstName": "Tara", "articles": [{"headline": "Amazing 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_reporters(reporter, info):
+ return reporter.articles.all()
+
+ 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,
+ )
+
+ result = schema.execute(query)
+
+ assert not result.errors
+ assert result.data == {
+ "reporters": [
+ {"firstName": "Tara", "articles": [{"headline": "Amazing news"}]},
+ {"firstName": "Debra", "articles": []},
+ ]
+ }
class MyInstance(object):
@@ -27,15 +231,14 @@ data_loader = DataLoader(batch_load_fn=batch_load_fn)
class PermissionFieldTests(TestCase):
-
def test_permission_field(self):
MyType = object()
- field = DjangoField(MyType, permissions=['perm1', 'perm2'], source='resolver')
+ field = DjangoField(MyType, permissions=["perm1", "perm2"], source="resolver")
resolver = field.get_resolver(None)
class Viewer(object):
def has_perm(self, perm):
- return perm == 'perm2'
+ return perm == "perm2"
info = mock.Mock(context=mock.Mock(user=Viewer()))
@@ -43,7 +246,7 @@ class PermissionFieldTests(TestCase):
def test_permission_field_without_permission(self):
MyType = object()
- field = DjangoField(MyType, permissions=['perm1', 'perm2'], source='resolver')
+ field = DjangoField(MyType, permissions=["perm1", "perm2"], source="resolver")
resolver = field.get_resolver(field.resolver)
class Viewer(object):
@@ -57,10 +260,11 @@ class PermissionFieldTests(TestCase):
class DataLoaderFieldTests(TestCase):
-
def test_dataloaderfield(self):
MyType = object()
- data_loader_field = DataLoaderField(data_loader=data_loader, source_loader='key', type=MyType)
+ data_loader_field = DataLoaderField(
+ data_loader=data_loader, source_loader="key", type=MyType
+ )
resolver = data_loader_field.get_resolver(None)
instance = MyInstance()
@@ -69,7 +273,9 @@ class DataLoaderFieldTests(TestCase):
def test_dataloaderfield_many(self):
MyType = object()
- data_loader_field = DataLoaderField(data_loader=data_loader, source_loader='keys', type=MyType, load_many=True)
+ data_loader_field = DataLoaderField(
+ data_loader=data_loader, source_loader="keys", type=MyType, load_many=True
+ )
resolver = data_loader_field.get_resolver(None)
instance = MyInstance()
@@ -78,7 +284,9 @@ class DataLoaderFieldTests(TestCase):
def test_dataloaderfield_inner_prop(self):
MyType = object()
- data_loader_field = DataLoaderField(data_loader=data_loader, source_loader='InnerClass.key', type=MyType)
+ data_loader_field = DataLoaderField(
+ data_loader=data_loader, source_loader="InnerClass.key", type=MyType
+ )
resolver = data_loader_field.get_resolver(None)
instance = MyInstance()
@@ -87,8 +295,12 @@ class DataLoaderFieldTests(TestCase):
def test_dataloaderfield_many_inner_prop(self):
MyType = object()
- data_loader_field = DataLoaderField(data_loader=data_loader, source_loader='InnerClass.keys', type=MyType,
- load_many=True)
+ data_loader_field = DataLoaderField(
+ data_loader=data_loader,
+ source_loader="InnerClass.keys",
+ type=MyType,
+ load_many=True,
+ )
resolver = data_loader_field.get_resolver(None)
instance = MyInstance()
@@ -97,15 +309,19 @@ class DataLoaderFieldTests(TestCase):
def test_dataloaderfield_permissions(self):
MyType = object()
- data_loader_field = DataLoaderField(data_loader=data_loader, source_loader='key', type=MyType,
- permissions=['perm1', 'perm2'])
+ data_loader_field = DataLoaderField(
+ data_loader=data_loader,
+ source_loader="key",
+ type=MyType,
+ permissions=["perm1", "perm2"],
+ )
resolver = data_loader_field.get_resolver(None)
instance = MyInstance()
class Viewer(object):
def has_perm(self, perm):
- return perm == 'perm2'
+ return perm == "perm2"
info = mock.Mock(context=mock.Mock(user=Viewer()))
@@ -113,8 +329,12 @@ class DataLoaderFieldTests(TestCase):
def test_dataloaderfield_without_permissions(self):
MyType = object()
- data_loader_field = DataLoaderField(data_loader=data_loader, source_loader='key', type=MyType,
- permissions=['perm1', 'perm2'])
+ data_loader_field = DataLoaderField(
+ data_loader=data_loader,
+ source_loader="key",
+ type=MyType,
+ permissions=["perm1", "perm2"],
+ )
resolver = data_loader_field.get_resolver(None)
instance = MyInstance()
diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py
index 1716034..95db2d1 100644
--- a/graphene_django/tests/test_query.py
+++ b/graphene_django/tests/test_query.py
@@ -1,3 +1,4 @@
+import base64
import datetime
import pytest
@@ -7,6 +8,7 @@ from py.test import raises
from django.db.models import Q
+from graphql_relay import to_global_id
import graphene
from graphene.relay import Node
@@ -26,7 +28,7 @@ def test_should_query_only_fields():
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
- only_fields = ("articles",)
+ fields = ("articles",)
schema = graphene.Schema(query=ReporterType)
query = """
@@ -42,7 +44,7 @@ def test_should_query_simplelazy_objects():
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
- only_fields = ("id",)
+ fields = ("id",)
class Query(graphene.ObjectType):
reporter = graphene.Field(ReporterType)
@@ -226,12 +228,68 @@ def test_should_node():
assert result.data == expected
+def test_should_query_onetoone_fields():
+ film = Film(id=1)
+ film_details = FilmDetails(id=1, film=film)
+
+ class FilmNode(DjangoObjectType):
+ class Meta:
+ model = Film
+ interfaces = (Node,)
+
+ class FilmDetailsNode(DjangoObjectType):
+ class Meta:
+ model = FilmDetails
+ interfaces = (Node,)
+
+ class Query(graphene.ObjectType):
+ film = graphene.Field(FilmNode)
+ film_details = graphene.Field(FilmDetailsNode)
+
+ def resolve_film(root, info):
+ return film
+
+ def resolve_film_details(root, info):
+ return film_details
+
+ query = """
+ query FilmQuery {
+ filmDetails {
+ id
+ film {
+ id
+ }
+ }
+ film {
+ id
+ details {
+ id
+ }
+ }
+ }
+ """
+ expected = {
+ "filmDetails": {
+ "id": "RmlsbURldGFpbHNOb2RlOjE=",
+ "film": {"id": "RmlsbU5vZGU6MQ=="},
+ },
+ "film": {
+ "id": "RmlsbU5vZGU6MQ==",
+ "details": {"id": "RmlsbURldGFpbHNOb2RlOjE="},
+ },
+ }
+ schema = graphene.Schema(query=Query)
+ result = schema.execute(query)
+ assert not result.errors
+ assert result.data == expected
+
+
def test_should_query_connectionfields():
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
- only_fields = ("articles",)
+ fields = ("articles",)
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
@@ -271,7 +329,7 @@ def test_should_keep_annotations():
class Meta:
model = Reporter
interfaces = (Node,)
- only_fields = ("articles",)
+ fields = ("articles",)
class ArticleType(DjangoObjectType):
class Meta:
@@ -580,6 +638,8 @@ def test_should_error_if_first_is_greater_than_max():
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
+ assert Query.all_reporters.max_limit == 100
+
r = Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
@@ -621,6 +681,8 @@ def test_should_error_if_last_is_greater_than_max():
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
+ assert Query.all_reporters.max_limit == 100
+
r = Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
@@ -746,7 +808,7 @@ def test_should_query_connectionfields_with_manager():
schema = graphene.Schema(query=Query)
query = """
query ReporterLastQuery {
- allReporters(first: 2) {
+ allReporters(first: 1) {
edges {
node {
id
@@ -895,8 +957,7 @@ def test_should_handle_inherited_choices():
def test_proxy_model_support():
"""
- This test asserts that we can query for all Reporters,
- even if some are of a proxy model type at runtime.
+ This test asserts that we can query for all Reporters and proxied Reporters.
"""
class ReporterType(DjangoObjectType):
@@ -905,11 +966,17 @@ def test_proxy_model_support():
interfaces = (Node,)
use_connection = True
- reporter_1 = Reporter.objects.create(
+ class CNNReporterType(DjangoObjectType):
+ class Meta:
+ model = CNNReporter
+ interfaces = (Node,)
+ use_connection = True
+
+ reporter = Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
- reporter_2 = CNNReporter.objects.create(
+ cnn_reporter = CNNReporter.objects.create(
first_name="Some",
last_name="Guy",
email="someguy@cnn.com",
@@ -919,6 +986,7 @@ def test_proxy_model_support():
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
+ cnn_reporters = DjangoConnectionField(CNNReporterType)
schema = graphene.Schema(query=Query)
query = """
@@ -930,16 +998,28 @@ def test_proxy_model_support():
}
}
}
+ cnnReporters {
+ edges {
+ node {
+ id
+ }
+ }
+ }
}
"""
expected = {
"allReporters": {
"edges": [
- {"node": {"id": "UmVwb3J0ZXJUeXBlOjE="}},
- {"node": {"id": "UmVwb3J0ZXJUeXBlOjI="}},
+ {"node": {"id": to_global_id("ReporterType", reporter.id)}},
+ {"node": {"id": to_global_id("ReporterType", cnn_reporter.id)}},
]
- }
+ },
+ "cnnReporters": {
+ "edges": [
+ {"node": {"id": to_global_id("CNNReporterType", cnn_reporter.id)}}
+ ]
+ },
}
result = schema.execute(query)
@@ -947,31 +1027,10 @@ def test_proxy_model_support():
assert result.data == expected
-def test_proxy_model_fails():
- """
- This test asserts that if you try to query for a proxy model,
- that query will fail with:
- GraphQLError('Expected value of type "CNNReporterType" but got:
- CNNReporter.',)
-
- This is because a proxy model has the identical model definition
- to its superclass, and defines its behavior at runtime, rather than
- at the database level. Currently, filtering objects of the proxy models'
- type isn't supported. It would require a field on the model that would
- represent the type, and it doesn't seem like there is a clear way to
- enforce this pattern across all projects
- """
-
- class CNNReporterType(DjangoObjectType):
- class Meta:
- model = CNNReporter
- interfaces = (Node,)
- use_connection = True
-
+def test_should_resolve_get_queryset_connectionfields():
reporter_1 = Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
-
reporter_2 = CNNReporter.objects.create(
first_name="Some",
last_name="Guy",
@@ -980,13 +1039,22 @@ def test_proxy_model_fails():
reporter_type=2, # set this guy to be CNN
)
+ class ReporterType(DjangoObjectType):
+ class Meta:
+ model = Reporter
+ interfaces = (Node,)
+
+ @classmethod
+ def get_queryset(cls, queryset, info):
+ return queryset.filter(reporter_type=2)
+
class Query(graphene.ObjectType):
- all_reporters = DjangoConnectionField(CNNReporterType)
+ all_reporters = DjangoConnectionField(ReporterType)
schema = graphene.Schema(query=Query)
query = """
- query ProxyModelQuery {
- allReporters {
+ query ReporterPromiseConnectionQuery {
+ allReporters(first: 1) {
edges {
node {
id
@@ -996,14 +1064,111 @@ def test_proxy_model_fails():
}
"""
- expected = {
- "allReporters": {
- "edges": [
- {"node": {"id": "UmVwb3J0ZXJUeXBlOjE="}},
- {"node": {"id": "UmVwb3J0ZXJUeXBlOjI="}},
- ]
- }
- }
+ expected = {"allReporters": {"edges": [{"node": {"id": "UmVwb3J0ZXJUeXBlOjI="}}]}}
result = schema.execute(query)
- assert result.errors
+ assert not result.errors
+ assert result.data == expected
+
+
+def test_should_preserve_prefetch_related(django_assert_num_queries):
+ class ReporterType(DjangoObjectType):
+ class Meta:
+ model = Reporter
+ interfaces = (graphene.relay.Node,)
+
+ class FilmType(DjangoObjectType):
+ reporters = DjangoConnectionField(ReporterType)
+
+ class Meta:
+ model = Film
+ interfaces = (graphene.relay.Node,)
+
+ class Query(graphene.ObjectType):
+ films = DjangoConnectionField(FilmType)
+
+ def resolve_films(root, info):
+ qs = Film.objects.prefetch_related("reporters")
+ return qs
+
+ r1 = Reporter.objects.create(first_name="Dave", last_name="Smith")
+ r2 = Reporter.objects.create(first_name="Jane", last_name="Doe")
+
+ f1 = Film.objects.create()
+ f1.reporters.set([r1, r2])
+ f2 = Film.objects.create()
+ f2.reporters.set([r2])
+
+ query = """
+ query {
+ films {
+ edges {
+ node {
+ reporters {
+ edges {
+ node {
+ firstName
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ """
+ schema = graphene.Schema(query=Query)
+ with django_assert_num_queries(3) as captured:
+ result = schema.execute(query)
+ assert not result.errors
+
+
+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)
diff --git a/graphene_django/tests/test_schema.py b/graphene_django/tests/test_schema.py
index 452449b..2c2f74b 100644
--- a/graphene_django/tests/test_schema.py
+++ b/graphene_django/tests/test_schema.py
@@ -48,6 +48,6 @@ def test_should_map_only_few_fields():
class Reporter2(DjangoObjectType):
class Meta:
model = Reporter
- only_fields = ("id", "email")
+ fields = ("id", "email")
assert list(Reporter2._meta.fields.keys()) == ["id", "email"]
diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py
index c9245ae..74e98ec 100644
--- a/graphene_django/tests/test_types.py
+++ b/graphene_django/tests/test_types.py
@@ -1,9 +1,15 @@
+from collections import OrderedDict, defaultdict
+from textwrap import dedent
+
+import pytest
+from django.db import models
from mock import patch
from graphene import Interface, ObjectType, Schema, Connection, String, Field
from graphene.relay import Node
from .. import registry
+from ..settings import graphene_settings
from ..types import DjangoObjectType, DjangoObjectTypeOptions
from .models import Article as ArticleModel
from .models import Reporter as ReporterModel
@@ -165,10 +171,10 @@ type Reporter {
firstName: String!
lastName: String!
email: String!
- pets: [Reporter]
- aChoice: ReporterAChoice!
+ pets: [Reporter!]!
+ aChoice: ReporterAChoice
reporterType: ReporterReporterType
- articles(before: String, after: String, first: Int, last: Int): ArticleConnection
+ articles(before: String, after: String, first: Int, last: Int): ArticleConnection!
}
enum ReporterAChoice {
@@ -206,28 +212,370 @@ def with_local_registry(func):
@with_local_registry
def test_django_objecttype_only_fields():
- class Reporter(DjangoObjectType):
- class Meta:
- model = ReporterModel
- only_fields = ("id", "email", "films")
+ with pytest.warns(PendingDeprecationWarning):
+
+ class Reporter(DjangoObjectType):
+ class Meta:
+ model = ReporterModel
+ only_fields = ("id", "email", "films")
fields = list(Reporter._meta.fields.keys())
assert fields == ["id", "email", "films"]
@with_local_registry
-def test_django_objecttype_exclude_fields():
+def test_django_objecttype_fields():
class Reporter(DjangoObjectType):
class Meta:
model = ReporterModel
- exclude_fields = "email"
+ fields = ("id", "email", "films")
+
+ fields = list(Reporter._meta.fields.keys())
+ assert fields == ["id", "email", "films"]
+
+
+@with_local_registry
+def test_django_objecttype_only_fields_and_fields():
+ with pytest.raises(Exception):
+
+ class Reporter(DjangoObjectType):
+ class Meta:
+ model = ReporterModel
+ only_fields = ("id", "email", "films")
+ fields = ("id", "email", "films")
+
+
+@with_local_registry
+def test_django_objecttype_all_fields():
+ class Reporter(DjangoObjectType):
+ class Meta:
+ model = ReporterModel
+ fields = "__all__"
+
+ fields = list(Reporter._meta.fields.keys())
+ assert len(fields) == len(ReporterModel._meta.get_fields())
+
+
+@with_local_registry
+def test_django_objecttype_exclude_fields():
+ with pytest.warns(PendingDeprecationWarning):
+
+ class Reporter(DjangoObjectType):
+ class Meta:
+ model = ReporterModel
+ exclude_fields = ["email"]
fields = list(Reporter._meta.fields.keys())
assert "email" not in fields
+@with_local_registry
+def test_django_objecttype_exclude():
+ class Reporter(DjangoObjectType):
+ class Meta:
+ model = ReporterModel
+ exclude = ["email"]
+
+ fields = list(Reporter._meta.fields.keys())
+ assert "email" not in fields
+
+
+@with_local_registry
+def test_django_objecttype_exclude_fields_and_exclude():
+ with pytest.raises(Exception):
+
+ class Reporter(DjangoObjectType):
+ class Meta:
+ model = ReporterModel
+ exclude = ["email"]
+ exclude_fields = ["email"]
+
+
+@with_local_registry
+def test_django_objecttype_exclude_and_only():
+ with pytest.raises(AssertionError):
+
+ class Reporter(DjangoObjectType):
+ class Meta:
+ model = ReporterModel
+ exclude = ["email"]
+ fields = ["id"]
+
+
+@with_local_registry
+def test_django_objecttype_fields_exclude_type_checking():
+ with pytest.raises(TypeError):
+
+ class Reporter(DjangoObjectType):
+ class Meta:
+ model = ReporterModel
+ fields = "foo"
+
+ with pytest.raises(TypeError):
+
+ class Reporter2(DjangoObjectType):
+ class Meta:
+ model = ReporterModel
+ 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:
+ @pytest.fixture
+ def PetModel(self):
+ class PetModel(models.Model):
+ kind = models.CharField(choices=(("cat", "Cat"), ("dog", "Dog")))
+ cuteness = models.IntegerField(
+ choices=((1, "Kind of cute"), (2, "Pretty cute"), (3, "OMG SO CUTE!!!"))
+ )
+
+ yield PetModel
+
+ # Clear Django model cache so we don't get warnings when creating the
+ # model multiple times
+ PetModel._meta.apps.all_models = defaultdict(OrderedDict)
+
+ def test_django_objecttype_convert_choices_enum_false(self, PetModel):
+ class Pet(DjangoObjectType):
+ class Meta:
+ model = PetModel
+ convert_choices_to_enum = False
+
+ class Query(ObjectType):
+ pet = Field(Pet)
+
+ schema = Schema(query=Query)
+
+ assert str(schema) == dedent(
+ """\
+ schema {
+ query: Query
+ }
+
+ type Pet {
+ id: ID!
+ kind: String!
+ cuteness: Int!
+ }
+
+ type Query {
+ pet: Pet
+ }
+ """
+ )
+
+ def test_django_objecttype_convert_choices_enum_list(self, PetModel):
+ class Pet(DjangoObjectType):
+ class Meta:
+ model = PetModel
+ convert_choices_to_enum = ["kind"]
+
+ class Query(ObjectType):
+ pet = Field(Pet)
+
+ schema = Schema(query=Query)
+
+ assert str(schema) == dedent(
+ """\
+ schema {
+ query: Query
+ }
+
+ type Pet {
+ id: ID!
+ kind: PetModelKind!
+ cuteness: Int!
+ }
+
+ enum PetModelKind {
+ CAT
+ DOG
+ }
+
+ type Query {
+ pet: Pet
+ }
+ """
+ )
+
+ def test_django_objecttype_convert_choices_enum_empty_list(self, PetModel):
+ class Pet(DjangoObjectType):
+ class Meta:
+ model = PetModel
+ convert_choices_to_enum = []
+
+ class Query(ObjectType):
+ pet = Field(Pet)
+
+ schema = Schema(query=Query)
+
+ assert str(schema) == dedent(
+ """\
+ schema {
+ query: Query
+ }
+
+ type Pet {
+ id: ID!
+ kind: String!
+ cuteness: Int!
+ }
+
+ type Query {
+ pet: Pet
+ }
+ """
+ )
+
+ def test_django_objecttype_convert_choices_enum_naming_collisions(self, PetModel):
+ graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = True
+
+ class PetModelKind(DjangoObjectType):
+ class Meta:
+ model = PetModel
+ fields = ["id", "kind"]
+
+ class Query(ObjectType):
+ pet = Field(PetModelKind)
+
+ schema = Schema(query=Query)
+
+ assert str(schema) == dedent(
+ """\
+ schema {
+ query: Query
+ }
+
+ type PetModelKind {
+ id: ID!
+ kind: TestsPetModelKindChoices!
+ }
+
+ type Query {
+ pet: PetModelKind
+ }
+
+ enum TestsPetModelKindChoices {
+ CAT
+ DOG
+ }
+ """
+ )
+ graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = False
+
+ def test_django_objecttype_choices_custom_enum_name(self, PetModel):
+ graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME = (
+ "graphene_django.tests.test_types.custom_enum_name"
+ )
+
+ class PetModelKind(DjangoObjectType):
+ class Meta:
+ model = PetModel
+ fields = ["id", "kind"]
+
+ class Query(ObjectType):
+ pet = Field(PetModelKind)
+
+ schema = Schema(query=Query)
+
+ assert str(schema) == dedent(
+ """\
+ schema {
+ query: Query
+ }
+
+ enum CustomEnumKind {
+ CAT
+ DOG
+ }
+
+ type PetModelKind {
+ id: ID!
+ kind: CustomEnumKind!
+ }
+
+ type Query {
+ pet: PetModelKind
+ }
+ """
+ )
+
+ graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME = None
+
+
def extra_field_resolver(root, info, **kwargs):
- return 'extra field'
+ return "extra field"
class PermissionArticle(DjangoObjectType):
@@ -235,27 +583,28 @@ class PermissionArticle(DjangoObjectType):
class Meta(object):
"""Meta Class"""
+
field_to_permission = {
- 'headline': ('content_type.permission1',),
- 'pub_date': ('content_type.permission2',)
+ "headline": ("content_type.permission1",),
+ "pub_date": ("content_type.permission2",),
}
permission_to_field = {
- 'content_type.permission3': ('headline', 'reporter', 'extra_field',)
+ "content_type.permission3": ("headline", "reporter", "extra_field",)
}
model = ArticleModel
extra_field = Field(String, resolver=extra_field_resolver)
def resolve_headline(self, info, **kwargs):
- return 'headline'
+ return "headline"
def test_django_permissions():
expected = {
- 'headline': ('content_type.permission1', 'content_type.permission3'),
- 'pub_date': ('content_type.permission2',),
- 'reporter': ('content_type.permission3',),
- 'extra_field': ('content_type.permission3',),
+ "headline": ("content_type.permission1", "content_type.permission3"),
+ "pub_date": ("content_type.permission2",),
+ "reporter": ("content_type.permission3",),
+ "extra_field": ("content_type.permission3",),
}
assert PermissionArticle.field_permissions == expected
@@ -265,15 +614,16 @@ def test_permission_resolver():
class Viewer(object):
def has_perm(self, perm):
- return perm == 'content_type.permission3'
+ return perm == "content_type.permission3"
class Info(object):
class Context(object):
user = Viewer()
+
context = Context()
resolved = PermissionArticle.resolve_headline(MyType, Info())
- assert resolved == 'headline'
+ assert resolved == "headline"
def test_resolver_without_permission():
@@ -286,6 +636,7 @@ def test_resolver_without_permission():
class Info(object):
class Context(object):
user = Viewer()
+
context = Context()
resolved = PermissionArticle.resolve_headline(MyType, Info())
@@ -297,15 +648,16 @@ def test_permission_resolver_to_field():
class Viewer(object):
def has_perm(self, perm):
- return perm == 'content_type.permission3'
+ return perm == "content_type.permission3"
class Info(object):
class Context(object):
user = Viewer()
+
context = Context()
resolved = PermissionArticle.resolve_extra_field(MyType, Info())
- assert resolved == 'extra field'
+ assert resolved == "extra field"
def test_resolver_to_field_without_permission():
@@ -313,11 +665,12 @@ def test_resolver_to_field_without_permission():
class Viewer(object):
def has_perm(self, perm):
- return perm != 'content_type.permission3'
+ return perm != "content_type.permission3"
class Info(object):
class Context(object):
user = Viewer()
+
context = Context()
resolved = PermissionArticle.resolve_extra_field(MyType, Info())
diff --git a/graphene_django/tests/test_utils.py b/graphene_django/tests/test_utils.py
index e1068c1..233ec06 100644
--- a/graphene_django/tests/test_utils.py
+++ b/graphene_django/tests/test_utils.py
@@ -1,4 +1,7 @@
-from ..utils import get_model_fields, has_permissions
+from django.utils.translation import gettext_lazy
+
+from graphene_django.utils.utils import has_permissions
+from ..utils import camelize, get_model_fields
from .models import Film, Reporter
@@ -12,6 +15,24 @@ def test_get_model_fields_no_duplication():
assert len(film_fields) == len(film_name_set)
+def test_camelize():
+ assert camelize({}) == {}
+ assert camelize("value_a") == "value_a"
+ assert camelize({"value_a": "value_b"}) == {"valueA": "value_b"}
+ assert camelize({"value_a": ["value_b"]}) == {"valueA": ["value_b"]}
+ assert camelize({"value_a": ["value_b"]}) == {"valueA": ["value_b"]}
+ assert camelize({"nested_field": {"value_a": ["error"], "value_b": ["error"]}}) == {
+ "nestedField": {"valueA": ["error"], "valueB": ["error"]}
+ }
+ assert camelize({"value_a": gettext_lazy("value_b")}) == {"valueA": "value_b"}
+ assert camelize({"value_a": [gettext_lazy("value_b")]}) == {"valueA": ["value_b"]}
+ assert camelize(gettext_lazy("value_a")) == "value_a"
+ assert camelize({gettext_lazy("value_a"): gettext_lazy("value_b")}) == {
+ "valueA": "value_b"
+ }
+ assert camelize({0: {"field_a": ["errors"]}}) == {0: {"fieldA": ["errors"]}}
+
+
def test_has_permissions():
class Viewer(object):
@staticmethod
diff --git a/graphene_django/types.py b/graphene_django/types.py
index 6ccd3c8..01838e4 100644
--- a/graphene_django/types.py
+++ b/graphene_django/types.py
@@ -1,18 +1,39 @@
+import warnings
from collections import OrderedDict
from functools import partial
+import six
+from django.db.models import Model
from django.utils.functional import SimpleLazyObject
+
+import graphene
from graphene import Field, NonNull
from graphene.relay import Connection, Node
from graphene.types.objecttype import ObjectType, ObjectTypeOptions
from graphene.types.utils import yank_fields_from_attrs
+from graphene.utils.str_converters import to_camel_case
+from graphene_django.utils.utils import auth_resolver
from .converter import convert_django_field_with_choices
from .registry import Registry, get_global_registry
-from .utils import DJANGO_FILTER_INSTALLED, get_model_fields, is_valid_django_model, auth_resolver
+from .settings import graphene_settings
+from .utils import (
+ DJANGO_FILTER_INSTALLED,
+ camelize,
+ get_model_fields,
+ is_valid_django_model,
+)
+
+if six.PY3:
+ from typing import Type
-def construct_fields(model, registry, only_fields, exclude_fields):
+ALL_FIELDS = "__all__"
+
+
+def construct_fields(
+ model, registry, only_fields, exclude_fields, convert_choices_to_enum
+):
_model_fields = get_model_fields(model)
fields = OrderedDict()
@@ -27,12 +48,91 @@ def construct_fields(model, registry, only_fields, exclude_fields):
# in there. Or when we exclude this field in exclude_fields.
# Or when there is no back reference.
continue
- converted = convert_django_field_with_choices(field, registry)
+
+ _convert_choices_to_enum = convert_choices_to_enum
+ if not isinstance(_convert_choices_to_enum, bool):
+ # then `convert_choices_to_enum` is a list of field names to convert
+ if name in _convert_choices_to_enum:
+ _convert_choices_to_enum = True
+ else:
+ _convert_choices_to_enum = False
+
+ converted = convert_django_field_with_choices(
+ field, registry, convert_choices_to_enum=_convert_choices_to_enum
+ )
fields[name] = converted
return fields
+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())
+ 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_,
+ )
+ )
+
+
def get_auth_resolver(name, permissions, resolver=None):
"""
Get middleware resolver to handle field permissions
@@ -50,38 +150,27 @@ class DjangoObjectTypeOptions(ObjectTypeOptions):
connection = None # type: Type[Connection]
filter_fields = ()
+ filterset_class = None
class DjangoObjectType(ObjectType):
- """
- DjangoObjectType inheritance to handle field authorization
- Accepts field's permissions description as:
-
- class Meta:
-
- field_to_permission = {
- 'restricted_field': ('permission1', 'permission2')
- }
-
- permission_to_field = {
- 'permission': ('restricted_field_1', 'restricted_field_2')
- }
-
- At least one of the permissions must be accomplished in order to resolve the field.
- """
@classmethod
def __init_subclass_with_meta__(
cls,
model=None,
registry=None,
skip_registry=False,
- only_fields=(),
- exclude_fields=(),
+ only_fields=(), # deprecated in favour of `fields`
+ fields=(),
+ exclude_fields=(), # deprecated in favour of `exclude`
+ exclude=(),
filter_fields=None,
+ filterset_class=None,
connection=None,
connection_class=None,
use_connection=None,
interfaces=(),
+ convert_choices_to_enum=True,
field_to_permission=None,
permission_to_field=None,
_meta=None,
@@ -99,11 +188,60 @@ class DjangoObjectType(ObjectType):
'Registry, received "{}".'
).format(cls.__name__, registry)
- if not DJANGO_FILTER_INSTALLED and filter_fields:
- raise Exception("Can only set filter_fields if Django-Filter is installed")
+ if filter_fields and filterset_class:
+ raise Exception("Can't set both filter_fields and filterset_class")
+
+ if not DJANGO_FILTER_INSTALLED and (filter_fields or filterset_class):
+ raise Exception(
+ (
+ "Can only set filter_fields or filterset_class if "
+ "Django-Filter is installed"
+ )
+ )
+
+ assert not (fields and exclude), (
+ "Cannot set both 'fields' and 'exclude' options on "
+ "DjangoObjectType {class_name}.".format(class_name=cls.__name__)
+ )
+
+ # Alias only_fields -> fields
+ if only_fields and fields:
+ raise Exception("Can't set both only_fields and fields")
+ if only_fields:
+ warnings.warn(
+ "Defining `only_fields` is deprecated in favour of `fields`.",
+ PendingDeprecationWarning,
+ stacklevel=2,
+ )
+ fields = only_fields
+ if fields and fields != ALL_FIELDS and not isinstance(fields, (list, tuple)):
+ raise TypeError(
+ 'The `fields` option must be a list or tuple or "__all__". '
+ "Got %s." % type(fields).__name__
+ )
+
+ if fields == ALL_FIELDS:
+ fields = None
+
+ # Alias exclude_fields -> exclude
+ if exclude_fields and exclude:
+ raise Exception("Can't set both exclude_fields and exclude")
+ if exclude_fields:
+ warnings.warn(
+ "Defining `exclude_fields` is deprecated in favour of `exclude`.",
+ PendingDeprecationWarning,
+ stacklevel=2,
+ )
+ exclude = exclude_fields
+ if exclude and not isinstance(exclude, (list, tuple)):
+ raise TypeError(
+ "The `exclude` option must be a list or tuple. Got %s."
+ % type(exclude).__name__
+ )
django_fields = yank_fields_from_attrs(
- construct_fields(model, registry, only_fields, exclude_fields), _as=Field
+ construct_fields(model, registry, fields, exclude, convert_choices_to_enum),
+ _as=Field,
)
if use_connection is None and interfaces:
@@ -131,10 +269,13 @@ class DjangoObjectType(ObjectType):
_meta.model = model
_meta.registry = registry
_meta.filter_fields = filter_fields
+ _meta.filterset_class = filterset_class
_meta.fields = django_fields
_meta.connection = connection
- field_permissions = cls.__get_field_permissions__(field_to_permission, permission_to_field)
+ field_permissions = cls.__get_field_permissions__(
+ field_to_permission, permission_to_field
+ )
if field_permissions:
cls.__set_as_nullable__(field_permissions, model, registry)
@@ -142,6 +283,9 @@ class DjangoObjectType(ObjectType):
_meta=_meta, interfaces=interfaces, **options
)
+ # Validate fields
+ validate_fields(cls, model, _meta.fields, fields, exclude)
+
if field_permissions:
cls.__set_permissions_resolvers__(field_permissions)
@@ -186,23 +330,27 @@ class DjangoObjectType(ObjectType):
def __set_permissions_resolvers__(cls, permissions):
"""Set permission resolvers"""
for field_name, field_permissions in permissions.items():
- attr = 'resolve_{}'.format(field_name)
- resolver = getattr(cls._meta.fields[field_name], 'resolver', None) or getattr(cls, attr, None)
+ attr = "resolve_{}".format(field_name)
+ resolver = getattr(
+ cls._meta.fields[field_name], "resolver", None
+ ) or getattr(cls, attr, None)
- if not hasattr(field_permissions, '__iter__'):
+ if not hasattr(field_permissions, "__iter__"):
field_permissions = tuple(field_permissions)
- setattr(cls, attr, get_auth_resolver(field_name, field_permissions, resolver))
+ setattr(
+ cls, attr, get_auth_resolver(field_name, field_permissions, resolver)
+ )
@classmethod
def __set_as_nullable__(cls, field_permissions, model, registry):
"""Set restricted fields as nullable"""
django_fields = yank_fields_from_attrs(
- construct_fields(model, registry, field_permissions.keys(), ()),
+ construct_fields(model, registry, field_permissions.keys(), (), True),
_as=Field,
)
for name, field in django_fields.items():
- if hasattr(field, '_type') and isinstance(field._type, NonNull):
+ if hasattr(field, "_type") and isinstance(field._type, NonNull):
field._type = field._type._of_type
setattr(cls, name, field)
@@ -219,12 +367,36 @@ class DjangoObjectType(ObjectType):
if not is_valid_django_model(type(root)):
raise Exception(('Received incompatible instance "{}".').format(root))
- model = root._meta.model._meta.concrete_model
+ if cls._meta.model._meta.proxy:
+ model = root._meta.model
+ else:
+ model = root._meta.model._meta.concrete_model
+
return model == cls._meta.model
+ @classmethod
+ def get_queryset(cls, queryset, info):
+ return queryset
+
@classmethod
def get_node(cls, info, id):
+ queryset = cls.get_queryset(cls._meta.model.objects, info)
try:
- return cls._meta.model.objects.get(pk=id)
+ return queryset.get(pk=id)
except cls._meta.model.DoesNotExist:
return None
+
+
+class ErrorType(ObjectType):
+ field = graphene.String(required=True)
+ messages = graphene.List(graphene.NonNull(graphene.String), required=True)
+
+ @classmethod
+ def from_errors(cls, errors):
+ data = {
+ to_camel_case(key)
+ if key != "__all__" and graphene_settings.CAMELCASE_ERRORS
+ else key: value
+ for key, value in errors.items()
+ }
+ return [cls(field=key, messages=value) for key, value in data.items()]
diff --git a/graphene_django/utils/__init__.py b/graphene_django/utils/__init__.py
new file mode 100644
index 0000000..9d8658b
--- /dev/null
+++ b/graphene_django/utils/__init__.py
@@ -0,0 +1,21 @@
+from .testing import GraphQLTestCase
+from .utils import (
+ DJANGO_FILTER_INSTALLED,
+ camelize,
+ get_model_fields,
+ get_reverse_fields,
+ import_single_dispatch,
+ is_valid_django_model,
+ maybe_queryset,
+)
+
+__all__ = [
+ "DJANGO_FILTER_INSTALLED",
+ "get_reverse_fields",
+ "maybe_queryset",
+ "get_model_fields",
+ "camelize",
+ "is_valid_django_model",
+ "import_single_dispatch",
+ "GraphQLTestCase",
+]
diff --git a/graphene_django/utils/testing.py b/graphene_django/utils/testing.py
new file mode 100644
index 0000000..8a9b994
--- /dev/null
+++ b/graphene_django/utils/testing.py
@@ -0,0 +1,85 @@
+import json
+
+from django.test import TestCase, Client
+
+
+class GraphQLTestCase(TestCase):
+ """
+ Based on: https://www.sam.today/blog/testing-graphql-with-graphene-django/
+ """
+
+ # URL to graphql endpoint
+ GRAPHQL_URL = "/graphql/"
+ # Here you need to set your graphql schema for the tests
+ GRAPHQL_SCHEMA = None
+
+ @classmethod
+ def setUpClass(cls):
+ super(GraphQLTestCase, cls).setUpClass()
+
+ if not cls.GRAPHQL_SCHEMA:
+ raise AttributeError(
+ "Variable GRAPHQL_SCHEMA not defined in GraphQLTestCase."
+ )
+
+ cls._client = Client()
+
+ def query(self, query, op_name=None, input_data=None, variables=None, headers=None):
+ """
+ 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.
+
+ Returns:
+ Response object from client
+ """
+ body = {"query": query}
+ if op_name:
+ body["operation_name"] = op_name
+ if variables:
+ body["variables"] = variables
+ if input_data:
+ if variables in body:
+ body["variables"]["input"] = input_data
+ else:
+ body["variables"] = {"input": input_data}
+ if headers:
+ resp = self._client.post(
+ self.GRAPHQL_URL,
+ json.dumps(body),
+ content_type="application/json",
+ **headers
+ )
+ else:
+ resp = self._client.post(
+ self.GRAPHQL_URL, json.dumps(body), content_type="application/json"
+ )
+ return resp
+
+ def assertResponseNoErrors(self, resp):
+ """
+ Assert that the call went through correctly. 200 means the syntax is ok, if there are no `errors`,
+ the call was fine.
+ :resp HttpResponse: Response
+ """
+ self.assertEqual(resp.status_code, 200)
+ content = json.loads(resp.content)
+ self.assertNotIn("errors", list(content.keys()))
+
+ def assertResponseHasErrors(self, resp):
+ """
+ Assert that the call was failing. Take care: Even with errors, GraphQL returns status 200!
+ :resp HttpResponse: Response
+ """
+ content = json.loads(resp.content)
+ self.assertIn("errors", list(content.keys()))
diff --git a/graphene_django/utils.py b/graphene_django/utils/utils.py
similarity index 80%
rename from graphene_django/utils.py
rename to graphene_django/utils/utils.py
index 91d5709..4bbfdd6 100644
--- a/graphene_django/utils.py
+++ b/graphene_django/utils/utils.py
@@ -1,18 +1,17 @@
import inspect
+import six
from django.core.exceptions import PermissionDenied
from django.db import models
from django.db.models.manager import Manager
-
# from graphene.utils import LazyList
from graphene.types.resolver import get_default_resolver
from graphene.utils.get_unbound_function import get_unbound_function
+from django.utils.encoding import force_text
+from django.utils.functional import Promise
-
-class LazyList(object):
- pass
-
+from graphene.utils.str_converters import to_camel_case
try:
import django_filters # noqa
@@ -22,13 +21,35 @@ except ImportError:
DJANGO_FILTER_INSTALLED = False
+def isiterable(value):
+ try:
+ iter(value)
+ except TypeError:
+ return False
+ return True
+
+
+def _camelize_django_str(s):
+ if isinstance(s, Promise):
+ s = force_text(s)
+ return to_camel_case(s) if isinstance(s, six.string_types) else s
+
+
+def camelize(data):
+ if isinstance(data, dict):
+ return {_camelize_django_str(k): camelize(v) for k, v in data.items()}
+ if isiterable(data) and not isinstance(data, (six.string_types, Promise)):
+ return [camelize(d) for d in data]
+ return data
+
+
def get_reverse_fields(model, local_field_names):
for name, attr in model.__dict__.items():
# Don't duplicate any local fields
if name in local_field_names:
continue
- # Django =>1.9 uses 'rel', django <1.9 uses 'related'
+ # "rel" for FK and M2M relations and "related" for O2O Relations
related = getattr(attr, "rel", None) or getattr(attr, "related", None)
if isinstance(related, models.ManyToOneRel):
yield (name, related)
@@ -111,7 +132,16 @@ def resolve_bound_resolver(resolver, root, info, **args):
return resolver(root, info, **args)
-def auth_resolver(parent_resolver, permissions, attname, default_value, raise_exception, root, info, **args):
+def auth_resolver(
+ parent_resolver,
+ permissions,
+ attname,
+ default_value,
+ raise_exception,
+ root,
+ info,
+ **args
+):
"""
Middleware resolver to check viewer's permissions
:param parent_resolver: Field resolver
@@ -125,7 +155,7 @@ def auth_resolver(parent_resolver, permissions, attname, default_value, raise_ex
:return: Resolved field. None if the viewer does not have permission to access the field.
"""
# Get viewer from context
- if not hasattr(info.context, 'user'):
+ if not hasattr(info.context, "user"):
raise PermissionDenied()
user = info.context.user
diff --git a/graphene_django/views.py b/graphene_django/views.py
index be7ccf9..4c58839 100644
--- a/graphene_django/views.py
+++ b/graphene_django/views.py
@@ -51,8 +51,9 @@ def instantiate_middleware(middlewares):
class GraphQLView(View):
- graphiql_version = "0.11.10"
+ graphiql_version = "0.14.0"
graphiql_template = "graphene/graphiql.html"
+ react_version = "16.8.6"
schema = None
graphiql = False
@@ -124,6 +125,13 @@ class GraphQLView(View):
data = self.parse_body(request)
show_graphiql = self.graphiql and self.can_display_graphiql(request, data)
+ if show_graphiql:
+ return self.render_graphiql(
+ request,
+ graphiql_version=self.graphiql_version,
+ react_version=self.react_version,
+ )
+
if self.batch:
responses = [self.get_response(request, entry) for entry in data]
result = "[{}]".format(
@@ -137,19 +145,6 @@ class GraphQLView(View):
else:
result, status_code = self.get_response(request, data, show_graphiql)
- if show_graphiql:
- query, variables, operation_name, id = self.get_graphql_params(
- request, data
- )
- return self.render_graphiql(
- request,
- graphiql_version=self.graphiql_version,
- query=query or "",
- variables=json.dumps(variables) or "",
- operation_name=operation_name or "",
- result=result or "",
- )
-
return HttpResponse(
status=status_code, content=result, content_type="application/json"
)
@@ -279,10 +274,10 @@ class GraphQLView(View):
extra_options["executor"] = self.executor
return document.execute(
- root=self.get_root_value(request),
- variables=variables,
+ root_value=self.get_root_value(request),
+ variable_values=variables,
operation_name=operation_name,
- context=self.get_context(request),
+ context_value=self.get_context(request),
middleware=self.get_middleware(request),
**extra_options
)
diff --git a/setup.cfg b/setup.cfg
index 546ad67..def0b67 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -5,11 +5,41 @@ test=pytest
universal=1
[flake8]
-exclude = setup.py,docs/*,examples/*,tests,graphene_django/debug/sql/*
+exclude = docs,graphene_django/debug/sql/*,migrations
max-line-length = 120
+select =
+ # Dictionary key repeated
+ F601,
+ # Ensure use of ==/!= to compare with str, bytes and int literals
+ F632,
+ # Redefinition of unused name
+ F811,
+ # Using an undefined variable
+ F821,
+ # Defining an undefined variable in __all__
+ F822,
+ # Using a variable before it is assigned
+ F823,
+ # Duplicate argument in function declaration
+ F831,
+ # Black would format this line
+ BLK,
+ # Do not use bare except
+ B001,
+ # Don't allow ++n. You probably meant n += 1
+ B002,
+ # Do not use mutable structures for argument defaults
+ B006,
+ # Do not perform calls in argument defaults
+ B008
[coverage:run]
omit = */tests/*
[isort]
known_first_party=graphene,graphene_django
+multi_line_output=3
+include_trailing_comma=True
+force_grid_wrap=0
+use_parentheses=True
+line_length=88
diff --git a/setup.py b/setup.py
index 3431cd5..560549a 100644
--- a/setup.py
+++ b/setup.py
@@ -24,6 +24,14 @@ tests_require = [
"pytest-django>=3.3.2",
] + rest_framework_require
+
+dev_requires = [
+ "black==19.10b0",
+ "flake8==3.7.9",
+ "flake8-black==0.1.1",
+ "flake8-bugbear==20.1.4",
+] + tests_require
+
setup(
name="graphene-django",
version=version,
@@ -49,7 +57,7 @@ setup(
packages=find_packages(exclude=["tests"]),
install_requires=[
"six>=1.10.0",
- "graphene>=2.1.3,<3",
+ "graphene>=2.1.7,<3",
"graphql-core>=2.1.0,<3",
"Django>=1.11",
"singledispatch>=3.4.0.3",
@@ -58,7 +66,11 @@ setup(
setup_requires=["pytest-runner"],
tests_require=tests_require,
rest_framework_require=rest_framework_require,
- extras_require={"test": tests_require, "rest_framework": rest_framework_require},
+ extras_require={
+ "test": tests_require,
+ "rest_framework": rest_framework_require,
+ "dev": dev_requires,
+ },
include_package_data=True,
zip_safe=False,
platforms="any",
diff --git a/tox.ini b/tox.ini
new file mode 100644
index 0000000..feeacfd
--- /dev/null
+++ b/tox.ini
@@ -0,0 +1,42 @@
+[tox]
+envlist =
+ py{27,35,36,37,38}-django{111,20,21,22,master},
+ py{36,37,38}-django30,
+ black,flake8
+
+[travis:env]
+DJANGO =
+ 1.11: django111
+ 2.0: django20
+ 2.1: django21
+ 2.2: django22
+ 3.0: django30
+ master: djangomaster
+
+[testenv]
+passenv = *
+usedevelop = True
+setenv =
+ DJANGO_SETTINGS_MODULE=django_test_settings
+deps =
+ -e.[test]
+ psycopg2
+ django111: Django>=1.11,<2.0
+ django20: Django>=2.0,<2.1
+ django21: Django>=2.1,<2.2
+ django22: Django>=2.2,<3.0
+ django30: Django>=3.0a1,<3.1
+ djangomaster: https://github.com/django/django/archive/master.zip
+commands = {posargs:py.test --cov=graphene_django graphene_django examples}
+
+[testenv:black]
+basepython = python3.7
+deps = -e.[dev]
+commands =
+ black --exclude "/migrations/" graphene_django examples setup.py --check
+
+[testenv:flake8]
+basepython = python3.7
+deps = -e.[dev]
+commands =
+ flake8 graphene_django examples