mirror of
https://github.com/graphql-python/graphene-django.git
synced 2024-11-25 02:54:06 +03:00
WIP: Merge master into v3 (#1086)
* merge master into v3 * fix order_by snake casing by checking if value is None, switch executor to execution_context_class since schema.execute no longer supports executor * fix linting by removing duplicate defintion and test of convert_form_field_to_string_list
This commit is contained in:
parent
48ed516b5e
commit
c049ab7470
13
.github/stale.yml
vendored
13
.github/stale.yml
vendored
|
@ -1,7 +1,7 @@
|
||||||
# Number of days of inactivity before an issue becomes stale
|
# Number of days of inactivity before an issue becomes stale
|
||||||
daysUntilStale: 120
|
daysUntilStale: false
|
||||||
# Number of days of inactivity before a stale issue is closed
|
# Number of days of inactivity before a stale issue is closed
|
||||||
daysUntilClose: 30
|
daysUntilClose: false
|
||||||
# Issues with these labels will never be considered stale
|
# Issues with these labels will never be considered stale
|
||||||
exemptLabels:
|
exemptLabels:
|
||||||
- pinned
|
- pinned
|
||||||
|
@ -13,9 +13,10 @@ exemptLabels:
|
||||||
# Label to use when marking an issue as stale
|
# Label to use when marking an issue as stale
|
||||||
staleLabel: wontfix
|
staleLabel: wontfix
|
||||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||||
markComment: >
|
markComment: false
|
||||||
This issue has been automatically marked as stale because it has not had
|
# markComment: >
|
||||||
recent activity. It will be closed if no further activity occurs. Thank you
|
# This issue has been automatically marked as stale because it has not had
|
||||||
for your contributions.
|
# 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
|
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||||
closeComment: false
|
closeComment: false
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
include README.md LICENSE
|
include README.md LICENSE
|
||||||
recursive-include graphene_django/templates *
|
recursive-include graphene_django/templates *
|
||||||
recursive-include graphene_django/static *
|
recursive-include graphene_django/static *
|
||||||
|
|
||||||
|
include examples/cookbook/cookbook/ingredients/fixtures/ingredients.json
|
||||||
|
include examples/cookbook-plain/cookbook/ingredients/fixtures/ingredients.json
|
11
README.md
11
README.md
|
@ -3,13 +3,13 @@
|
||||||
|
|
||||||
A [Django](https://www.djangoproject.com/) integration for [Graphene](http://graphene-python.org/).
|
A [Django](https://www.djangoproject.com/) integration for [Graphene](http://graphene-python.org/).
|
||||||
|
|
||||||
[![travis][travis-image]][travis-url]
|
[![build][build-image]][build-url]
|
||||||
[![pypi][pypi-image]][pypi-url]
|
[![pypi][pypi-image]][pypi-url]
|
||||||
[![Anaconda-Server Badge][conda-image]][conda-url]
|
[![Anaconda-Server Badge][conda-image]][conda-url]
|
||||||
[![coveralls][coveralls-image]][coveralls-url]
|
[![coveralls][coveralls-image]][coveralls-url]
|
||||||
|
|
||||||
[travis-image]: https://travis-ci.org/graphql-python/graphene-django.svg?branch=master&style=flat
|
[build-image]: https://github.com/graphql-python/graphene-django/workflows/Tests/badge.svg
|
||||||
[travis-url]: https://travis-ci.org/graphql-python/graphene-django
|
[build-url]: https://github.com/graphql-python/graphene-django/actions
|
||||||
[pypi-image]: https://img.shields.io/pypi/v/graphene-django.svg?style=flat
|
[pypi-image]: https://img.shields.io/pypi/v/graphene-django.svg?style=flat
|
||||||
[pypi-url]: https://pypi.org/project/graphene-django/
|
[pypi-url]: https://pypi.org/project/graphene-django/
|
||||||
[coveralls-image]: https://coveralls.io/repos/github/graphql-python/graphene-django/badge.svg?branch=master
|
[coveralls-image]: https://coveralls.io/repos/github/graphql-python/graphene-django/badge.svg?branch=master
|
||||||
|
@ -110,6 +110,11 @@ To learn more check out the following [examples](examples/):
|
||||||
* **Relay Schema**: [Starwars Relay example](examples/starwars)
|
* **Relay Schema**: [Starwars Relay example](examples/starwars)
|
||||||
|
|
||||||
|
|
||||||
|
## GraphQL testing clients
|
||||||
|
- [Firecamp](https://firecamp.io/graphql)
|
||||||
|
- [GraphiQL](https://github.com/graphql/graphiql)
|
||||||
|
|
||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
See [CONTRIBUTING.md](CONTRIBUTING.md)
|
See [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||||
|
|
|
@ -114,8 +114,8 @@ Contributing
|
||||||
See `CONTRIBUTING.md <CONTRIBUTING.md>`__.
|
See `CONTRIBUTING.md <CONTRIBUTING.md>`__.
|
||||||
|
|
||||||
.. |Graphene Logo| image:: http://graphene-python.org/favicon.png
|
.. |Graphene Logo| image:: http://graphene-python.org/favicon.png
|
||||||
.. |Build Status| image:: https://travis-ci.org/graphql-python/graphene-django.svg?branch=master
|
.. |Build Status| image:: https://github.com/graphql-python/graphene-django/workflows/Tests/badge.svg
|
||||||
:target: https://travis-ci.org/graphql-python/graphene-django
|
:target: https://github.com/graphql-python/graphene-django/actions
|
||||||
.. |PyPI version| image:: https://badge.fury.io/py/graphene-django.svg
|
.. |PyPI version| image:: https://badge.fury.io/py/graphene-django.svg
|
||||||
:target: https://badge.fury.io/py/graphene-django
|
:target: https://badge.fury.io/py/graphene-django
|
||||||
.. |Coverage Status| image:: https://coveralls.io/repos/graphql-python/graphene-django/badge.svg?branch=master&service=github
|
.. |Coverage Status| image:: https://coveralls.io/repos/graphql-python/graphene-django/badge.svg?branch=master&service=github
|
||||||
|
|
|
@ -1,35 +0,0 @@
|
||||||
import sys
|
|
||||||
import os
|
|
||||||
|
|
||||||
ROOT_PATH = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
sys.path.insert(0, ROOT_PATH + '/examples/')
|
|
||||||
|
|
||||||
SECRET_KEY = 1
|
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
|
||||||
'graphene_django',
|
|
||||||
'graphene_django.rest_framework',
|
|
||||||
'graphene_django.tests',
|
|
||||||
'starwars',
|
|
||||||
]
|
|
||||||
|
|
||||||
DATABASES = {
|
|
||||||
'default': {
|
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
|
||||||
'NAME': 'django_test.sqlite',
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TEMPLATES = [
|
|
||||||
{
|
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
|
||||||
'DIRS': [],
|
|
||||||
'APP_DIRS': True,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
GRAPHENE = {
|
|
||||||
'SCHEMA': 'graphene_django.tests.schema_view.schema'
|
|
||||||
}
|
|
||||||
|
|
||||||
ROOT_URLCONF = 'graphene_django.tests.urls'
|
|
|
@ -3,7 +3,7 @@ Django Debug Middleware
|
||||||
|
|
||||||
You can debug your GraphQL queries in a similar way to
|
You can debug your GraphQL queries in a similar way to
|
||||||
`django-debug-toolbar <https://django-debug-toolbar.readthedocs.org/>`__,
|
`django-debug-toolbar <https://django-debug-toolbar.readthedocs.org/>`__,
|
||||||
but outputing in the results in GraphQL response as fields, instead of
|
but outputting in the results in GraphQL response as fields, instead of
|
||||||
the graphical HTML interface.
|
the graphical HTML interface.
|
||||||
|
|
||||||
For that, you will need to add the plugin in your graphene schema.
|
For that, you will need to add the plugin in your graphene schema.
|
||||||
|
@ -43,7 +43,7 @@ And in your ``settings.py``:
|
||||||
Querying
|
Querying
|
||||||
--------
|
--------
|
||||||
|
|
||||||
You can query it for outputing all the sql transactions that happened in
|
You can query it for outputting all the sql transactions that happened in
|
||||||
the GraphQL request, like:
|
the GraphQL request, like:
|
||||||
|
|
||||||
.. code::
|
.. code::
|
||||||
|
|
|
@ -230,7 +230,7 @@ Extend the tuple of fields if you want to order by more than one field.
|
||||||
|
|
||||||
order_by = OrderingFilter(
|
order_by = OrderingFilter(
|
||||||
fields=(
|
fields=(
|
||||||
('created_at', 'created_at'),
|
('name', 'created_at'),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -73,7 +73,7 @@ To learn how to extend the schema object for your project, read the basic tutori
|
||||||
CSRF exempt
|
CSRF exempt
|
||||||
-----------
|
-----------
|
||||||
|
|
||||||
If have enabled `CSRF protection <https://docs.djangoproject.com/en/3.0/ref/csrf/>`_ in your Django app
|
If you have enabled `CSRF protection <https://docs.djangoproject.com/en/3.0/ref/csrf/>`_ in your Django app
|
||||||
you will find that it prevents your API clients from POSTing to the ``graphql`` endpoint. You can either
|
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``
|
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:
|
decorator:
|
||||||
|
|
|
@ -37,7 +37,8 @@ Simple example
|
||||||
# The class attributes define the response of the mutation
|
# The class attributes define the response of the mutation
|
||||||
question = graphene.Field(QuestionType)
|
question = graphene.Field(QuestionType)
|
||||||
|
|
||||||
def mutate(self, info, text, id):
|
@classmethod
|
||||||
|
def mutate(cls, root, info, text, id):
|
||||||
question = Question.objects.get(pk=id)
|
question = Question.objects.get(pk=id)
|
||||||
question.text = text
|
question.text = text
|
||||||
question.save()
|
question.save()
|
||||||
|
@ -231,3 +232,121 @@ This argument is also sent back to the client with the mutation result
|
||||||
(you do not have to do anything). For services that manage
|
(you do not have to do anything). For services that manage
|
||||||
a pool of many GraphQL requests in bulk, the ``clientIDMutation``
|
a pool of many GraphQL requests in bulk, the ``clientIDMutation``
|
||||||
allows you to match up a specific mutation with the response.
|
allows you to match up a specific mutation with the response.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
Django Database Transactions
|
||||||
|
----------------------------
|
||||||
|
|
||||||
|
Django gives you a few ways to control how database transactions are managed.
|
||||||
|
|
||||||
|
Tying transactions to HTTP requests
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
A common way to handle transactions in Django is to wrap each request in a transaction.
|
||||||
|
Set ``ATOMIC_REQUESTS`` settings to ``True`` in the configuration of each database for
|
||||||
|
which you want to enable this behavior.
|
||||||
|
|
||||||
|
It works like this. Before calling ``GraphQLView`` Django starts a transaction. If the
|
||||||
|
response is produced without problems, Django commits the transaction. If the view, a
|
||||||
|
``DjangoFormMutation`` or a ``DjangoModelFormMutation`` produces an exception, Django
|
||||||
|
rolls back the transaction.
|
||||||
|
|
||||||
|
.. warning::
|
||||||
|
|
||||||
|
While the simplicity of this transaction model is appealing, it also makes it
|
||||||
|
inefficient when traffic increases. Opening a transaction for every request has some
|
||||||
|
overhead. The impact on performance depends on the query patterns of your application
|
||||||
|
and on how well your database handles locking.
|
||||||
|
|
||||||
|
Check the next section for a better solution.
|
||||||
|
|
||||||
|
Tying transactions to mutations
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
A mutation can contain multiple fields, just like a query. There's one important
|
||||||
|
distinction between queries and mutations, other than the name:
|
||||||
|
|
||||||
|
..
|
||||||
|
|
||||||
|
`While query fields are executed in parallel, mutation fields run in series, one
|
||||||
|
after the other.`
|
||||||
|
|
||||||
|
This means that if we send two ``incrementCredits`` mutations in one request, the first
|
||||||
|
is guaranteed to finish before the second begins, ensuring that we don't end up with a
|
||||||
|
race condition with ourselves.
|
||||||
|
|
||||||
|
On the other hand, if the first ``incrementCredits`` runs successfully but the second
|
||||||
|
one does not, the operation cannot be retried as it is. That's why is a good idea to
|
||||||
|
run all mutation fields in a transaction, to guarantee all occur or nothing occurs.
|
||||||
|
|
||||||
|
To enable this behavior for all databases set the graphene ``ATOMIC_MUTATIONS`` settings
|
||||||
|
to ``True`` in your settings file:
|
||||||
|
|
||||||
|
.. code:: python
|
||||||
|
|
||||||
|
GRAPHENE = {
|
||||||
|
# ...
|
||||||
|
"ATOMIC_MUTATIONS": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
On the contrary, if you want to enable this behavior for a specific database, set
|
||||||
|
``ATOMIC_MUTATIONS`` to ``True`` in your database settings:
|
||||||
|
|
||||||
|
.. code:: python
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": {
|
||||||
|
# ...
|
||||||
|
"ATOMIC_MUTATIONS": True,
|
||||||
|
},
|
||||||
|
# ...
|
||||||
|
}
|
||||||
|
|
||||||
|
Now, given the following example mutation:
|
||||||
|
|
||||||
|
.. code::
|
||||||
|
|
||||||
|
mutation IncreaseCreditsTwice {
|
||||||
|
|
||||||
|
increaseCredits1: increaseCredits(input: { amount: 10 }) {
|
||||||
|
balance
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
increaseCredits2: increaseCredits(input: { amount: -1 }) {
|
||||||
|
balance
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
The server is going to return something like:
|
||||||
|
|
||||||
|
.. code:: json
|
||||||
|
|
||||||
|
{
|
||||||
|
"data": {
|
||||||
|
"increaseCredits1": {
|
||||||
|
"balance": 10.0,
|
||||||
|
"errors": []
|
||||||
|
},
|
||||||
|
"increaseCredits2": {
|
||||||
|
"balance": null,
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"field": "amount",
|
||||||
|
"message": "Amount should be a positive number"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
But the balance will remain the same.
|
||||||
|
|
|
@ -291,7 +291,7 @@ Where "foo" is the name of the field declared in the ``Query`` object.
|
||||||
class Query(graphene.ObjectType):
|
class Query(graphene.ObjectType):
|
||||||
foo = graphene.List(QuestionType)
|
foo = graphene.List(QuestionType)
|
||||||
|
|
||||||
def resolve_foo(root, info):
|
def resolve_foo(root, info, **kwargs):
|
||||||
id = kwargs.get("id")
|
id = kwargs.get("id")
|
||||||
return Question.objects.get(id)
|
return Question.objects.get(id)
|
||||||
|
|
||||||
|
|
|
@ -90,11 +90,12 @@ To use pytest define a simple fixture using the query helper below
|
||||||
.. code:: python
|
.. code:: python
|
||||||
|
|
||||||
# Create a fixture using the graphql_query helper and `client` fixture from `pytest-django`.
|
# Create a fixture using the graphql_query helper and `client` fixture from `pytest-django`.
|
||||||
|
import json
|
||||||
import pytest
|
import pytest
|
||||||
from graphene_django.utils.testing import graphql_query
|
from graphene_django.utils.testing import graphql_query
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client_query(client)
|
def client_query(client):
|
||||||
def func(*args, **kwargs):
|
def func(*args, **kwargs):
|
||||||
return graphql_query(*args, **kwargs, client=client)
|
return graphql_query(*args, **kwargs, client=client)
|
||||||
|
|
||||||
|
@ -102,7 +103,7 @@ To use pytest define a simple fixture using the query helper below
|
||||||
|
|
||||||
# Test you query using the client_query fixture
|
# Test you query using the client_query fixture
|
||||||
def test_some_query(client_query):
|
def test_some_query(client_query):
|
||||||
response = graphql_query(
|
response = client_query(
|
||||||
'''
|
'''
|
||||||
query {
|
query {
|
||||||
myModel {
|
myModel {
|
||||||
|
|
0
examples/__init__.py
Normal file
0
examples/__init__.py
Normal file
0
examples/cookbook-plain/__init__.py
Normal file
0
examples/cookbook-plain/__init__.py
Normal file
0
examples/cookbook/__init__.py
Normal file
0
examples/cookbook/__init__.py
Normal file
30
examples/django_test_settings.py
Normal file
30
examples/django_test_settings.py
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
ROOT_PATH = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, ROOT_PATH + "/examples/")
|
||||||
|
|
||||||
|
SECRET_KEY = 1
|
||||||
|
|
||||||
|
INSTALLED_APPS = [
|
||||||
|
"graphene_django",
|
||||||
|
"graphene_django.rest_framework",
|
||||||
|
"graphene_django.tests",
|
||||||
|
"examples.starwars",
|
||||||
|
]
|
||||||
|
|
||||||
|
DATABASES = {
|
||||||
|
"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "django_test.sqlite"}
|
||||||
|
}
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
|
"DIRS": [],
|
||||||
|
"APP_DIRS": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
GRAPHENE = {"SCHEMA": "graphene_django.tests.schema_view.schema"}
|
||||||
|
|
||||||
|
ROOT_URLCONF = "graphene_django.tests.urls"
|
|
@ -1,7 +1,7 @@
|
||||||
from .fields import DjangoConnectionField, DjangoListField
|
from .fields import DjangoConnectionField, DjangoListField
|
||||||
from .types import DjangoObjectType
|
from .types import DjangoObjectType
|
||||||
|
|
||||||
__version__ = "3.0.0b6"
|
__version__ = "3.0.0b7"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"__version__",
|
"__version__",
|
||||||
|
|
1
graphene_django/constants.py
Normal file
1
graphene_django/constants.py
Normal file
|
@ -0,0 +1 @@
|
||||||
|
MUTATION_ERRORS_FLAG = "graphene_mutation_has_errors"
|
|
@ -20,6 +20,7 @@ from graphene import (
|
||||||
NonNull,
|
NonNull,
|
||||||
String,
|
String,
|
||||||
Time,
|
Time,
|
||||||
|
Decimal,
|
||||||
)
|
)
|
||||||
from graphene.types.json import JSONString
|
from graphene.types.json import JSONString
|
||||||
from graphene.utils.str_converters import to_camel_case
|
from graphene.utils.str_converters import to_camel_case
|
||||||
|
@ -174,6 +175,10 @@ def convert_field_to_boolean(field, registry=None):
|
||||||
|
|
||||||
|
|
||||||
@convert_django_field.register(models.DecimalField)
|
@convert_django_field.register(models.DecimalField)
|
||||||
|
def convert_field_to_decimal(field, registry=None):
|
||||||
|
return Decimal(description=field.help_text, required=not field.null)
|
||||||
|
|
||||||
|
|
||||||
@convert_django_field.register(models.FloatField)
|
@convert_django_field.register(models.FloatField)
|
||||||
@convert_django_field.register(models.DurationField)
|
@convert_django_field.register(models.DurationField)
|
||||||
def convert_field_to_float(field, registry=None):
|
def convert_field_to_float(field, registry=None):
|
||||||
|
|
|
@ -3,11 +3,13 @@ from functools import partial
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
from graphql_relay.connection.arrayconnection import (
|
from graphql_relay.connection.arrayconnection import (
|
||||||
connection_from_array_slice,
|
connection_from_array_slice,
|
||||||
|
cursor_to_offset,
|
||||||
get_offset_with_default,
|
get_offset_with_default,
|
||||||
|
offset_to_cursor,
|
||||||
)
|
)
|
||||||
from promise import Promise
|
from promise import Promise
|
||||||
|
|
||||||
from graphene import NonNull
|
from graphene import Int, NonNull
|
||||||
from graphene.relay import ConnectionField
|
from graphene.relay import ConnectionField
|
||||||
from graphene.relay.connection import connection_adapter, page_info_adapter
|
from graphene.relay.connection import connection_adapter, page_info_adapter
|
||||||
from graphene.types import Field, List
|
from graphene.types import Field, List
|
||||||
|
@ -41,16 +43,16 @@ class DjangoListField(Field):
|
||||||
def model(self):
|
def model(self):
|
||||||
return self._underlying_type._meta.model
|
return self._underlying_type._meta.model
|
||||||
|
|
||||||
def get_default_queryset(self):
|
def get_manager(self):
|
||||||
return self.model._default_manager.get_queryset()
|
return self.model._default_manager
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def list_resolver(
|
def list_resolver(
|
||||||
django_object_type, resolver, default_queryset, root, info, **args
|
django_object_type, resolver, default_manager, root, info, **args
|
||||||
):
|
):
|
||||||
queryset = maybe_queryset(resolver(root, info, **args))
|
queryset = maybe_queryset(resolver(root, info, **args))
|
||||||
if queryset is None:
|
if queryset is None:
|
||||||
queryset = default_queryset
|
queryset = maybe_queryset(default_manager)
|
||||||
|
|
||||||
if isinstance(queryset, QuerySet):
|
if isinstance(queryset, QuerySet):
|
||||||
# Pass queryset to the DjangoObjectType get_queryset method
|
# Pass queryset to the DjangoObjectType get_queryset method
|
||||||
|
@ -64,10 +66,7 @@ class DjangoListField(Field):
|
||||||
_type = _type.of_type
|
_type = _type.of_type
|
||||||
django_object_type = _type.of_type.of_type
|
django_object_type = _type.of_type.of_type
|
||||||
return partial(
|
return partial(
|
||||||
self.list_resolver,
|
self.list_resolver, django_object_type, parent_resolver, self.get_manager(),
|
||||||
django_object_type,
|
|
||||||
parent_resolver,
|
|
||||||
self.get_default_queryset(),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -81,6 +80,7 @@ class DjangoConnectionField(ConnectionField):
|
||||||
"enforce_first_or_last",
|
"enforce_first_or_last",
|
||||||
graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST,
|
graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST,
|
||||||
)
|
)
|
||||||
|
kwargs.setdefault("offset", Int())
|
||||||
super(DjangoConnectionField, self).__init__(*args, **kwargs)
|
super(DjangoConnectionField, self).__init__(*args, **kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -131,18 +131,24 @@ class DjangoConnectionField(ConnectionField):
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def resolve_connection(cls, connection, args, iterable, max_limit=None):
|
def resolve_connection(cls, connection, args, iterable, max_limit=None):
|
||||||
|
# Remove the offset parameter and convert it to an after cursor.
|
||||||
|
offset = args.pop("offset", None)
|
||||||
|
after = args.get("after")
|
||||||
|
if offset:
|
||||||
|
if after:
|
||||||
|
offset += cursor_to_offset(after) + 1
|
||||||
|
# input offset starts at 1 while the graphene offset starts at 0
|
||||||
|
args["after"] = offset_to_cursor(offset - 1)
|
||||||
|
|
||||||
iterable = maybe_queryset(iterable)
|
iterable = maybe_queryset(iterable)
|
||||||
|
|
||||||
if isinstance(iterable, QuerySet):
|
if isinstance(iterable, QuerySet):
|
||||||
list_length = iterable.count()
|
list_length = iterable.count()
|
||||||
list_slice_length = (
|
|
||||||
min(max_limit, list_length) if max_limit is not None else list_length
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
list_length = len(iterable)
|
list_length = len(iterable)
|
||||||
list_slice_length = (
|
list_slice_length = (
|
||||||
min(max_limit, list_length) if max_limit is not None else list_length
|
min(max_limit, list_length) if max_limit is not None else list_length
|
||||||
)
|
)
|
||||||
|
|
||||||
# If after is higher than list_length, connection_from_list_slice
|
# If after is higher than list_length, connection_from_list_slice
|
||||||
# would try to do a negative slicing which makes django throw an
|
# would try to do a negative slicing which makes django throw an
|
||||||
|
@ -150,7 +156,10 @@ class DjangoConnectionField(ConnectionField):
|
||||||
after = min(get_offset_with_default(args.get("after"), -1) + 1, list_length)
|
after = min(get_offset_with_default(args.get("after"), -1) + 1, list_length)
|
||||||
|
|
||||||
if max_limit is not None and args.get("first", None) == None:
|
if max_limit is not None and args.get("first", None) == None:
|
||||||
args["first"] = max_limit
|
if args.get("last", None) != None:
|
||||||
|
after = list_length - args["last"]
|
||||||
|
else:
|
||||||
|
args["first"] = max_limit
|
||||||
|
|
||||||
connection = connection_from_array_slice(
|
connection = connection_from_array_slice(
|
||||||
iterable[after:],
|
iterable[after:],
|
||||||
|
@ -181,6 +190,8 @@ class DjangoConnectionField(ConnectionField):
|
||||||
):
|
):
|
||||||
first = args.get("first")
|
first = args.get("first")
|
||||||
last = args.get("last")
|
last = args.get("last")
|
||||||
|
offset = args.get("offset")
|
||||||
|
before = args.get("before")
|
||||||
|
|
||||||
if enforce_first_or_last:
|
if enforce_first_or_last:
|
||||||
assert first or last, (
|
assert first or last, (
|
||||||
|
@ -200,6 +211,11 @@ class DjangoConnectionField(ConnectionField):
|
||||||
).format(last, info.field_name, max_limit)
|
).format(last, info.field_name, max_limit)
|
||||||
args["last"] = min(last, max_limit)
|
args["last"] = min(last, max_limit)
|
||||||
|
|
||||||
|
if offset is not None:
|
||||||
|
assert before is None, (
|
||||||
|
"You can't provide a `before` value at the same time as an `offset` value to properly paginate the `{}` connection."
|
||||||
|
).format(info.field_name)
|
||||||
|
|
||||||
# eventually leads to DjangoObjectType's get_queryset (accepts queryset)
|
# eventually leads to DjangoObjectType's get_queryset (accepts queryset)
|
||||||
# or a resolve_foo (does not accept queryset)
|
# or a resolve_foo (does not accept queryset)
|
||||||
iterable = resolver(root, info, **args)
|
iterable = resolver(root, info, **args)
|
||||||
|
|
|
@ -3,6 +3,7 @@ from functools import partial
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from graphene.types.argument import to_arguments
|
from graphene.types.argument import to_arguments
|
||||||
|
from graphene.utils.str_converters import to_snake_case
|
||||||
from ..fields import DjangoConnectionField
|
from ..fields import DjangoConnectionField
|
||||||
from .utils import get_filtering_args_from_filterset, get_filterset_class
|
from .utils import get_filtering_args_from_filterset, get_filterset_class
|
||||||
|
|
||||||
|
@ -21,6 +22,7 @@ class DjangoFilterConnectionField(DjangoConnectionField):
|
||||||
self._fields = fields
|
self._fields = fields
|
||||||
self._provided_filterset_class = filterset_class
|
self._provided_filterset_class = filterset_class
|
||||||
self._filterset_class = None
|
self._filterset_class = None
|
||||||
|
self._filtering_args = None
|
||||||
self._extra_filter_meta = extra_filter_meta
|
self._extra_filter_meta = extra_filter_meta
|
||||||
self._base_args = None
|
self._base_args = None
|
||||||
super(DjangoFilterConnectionField, self).__init__(type, *args, **kwargs)
|
super(DjangoFilterConnectionField, self).__init__(type, *args, **kwargs)
|
||||||
|
@ -50,18 +52,31 @@ class DjangoFilterConnectionField(DjangoConnectionField):
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def filtering_args(self):
|
def filtering_args(self):
|
||||||
return get_filtering_args_from_filterset(self.filterset_class, self.node_type)
|
if not self._filtering_args:
|
||||||
|
self._filtering_args = get_filtering_args_from_filterset(
|
||||||
|
self.filterset_class, self.node_type
|
||||||
|
)
|
||||||
|
return self._filtering_args
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def resolve_queryset(
|
def resolve_queryset(
|
||||||
cls, connection, iterable, info, args, filtering_args, filterset_class
|
cls, connection, iterable, info, args, filtering_args, filterset_class
|
||||||
):
|
):
|
||||||
|
def filter_kwargs():
|
||||||
|
kwargs = {}
|
||||||
|
for k, v in args.items():
|
||||||
|
if k in filtering_args:
|
||||||
|
if k == "order_by" and v is not None:
|
||||||
|
v = to_snake_case(v)
|
||||||
|
kwargs[k] = v
|
||||||
|
return kwargs
|
||||||
|
|
||||||
qs = super(DjangoFilterConnectionField, cls).resolve_queryset(
|
qs = super(DjangoFilterConnectionField, cls).resolve_queryset(
|
||||||
connection, iterable, info, args
|
connection, iterable, info, args
|
||||||
)
|
)
|
||||||
filter_kwargs = {k: v for k, v in args.items() if k in filtering_args}
|
|
||||||
filterset = filterset_class(
|
filterset = filterset_class(
|
||||||
data=filter_kwargs, queryset=qs, request=info.context
|
data=filter_kwargs(), queryset=qs, request=info.context
|
||||||
)
|
)
|
||||||
if filterset.form.is_valid():
|
if filterset.form.is_valid():
|
||||||
return filterset.qs
|
return filterset.qs
|
||||||
|
|
|
@ -21,7 +21,7 @@ class ReporterFilter(django_filters.FilterSet):
|
||||||
model = Reporter
|
model = Reporter
|
||||||
fields = ["first_name", "last_name", "email", "pets"]
|
fields = ["first_name", "last_name", "email", "pets"]
|
||||||
|
|
||||||
order_by = OrderingFilter(fields=("pub_date",))
|
order_by = OrderingFilter(fields=("first_name",))
|
||||||
|
|
||||||
|
|
||||||
class PetFilter(django_filters.FilterSet):
|
class PetFilter(django_filters.FilterSet):
|
||||||
|
|
|
@ -62,7 +62,7 @@ def get_args(field):
|
||||||
|
|
||||||
|
|
||||||
def assert_arguments(field, *arguments):
|
def assert_arguments(field, *arguments):
|
||||||
ignore = ("after", "before", "first", "last", "order_by")
|
ignore = ("offset", "after", "before", "first", "last", "order_by")
|
||||||
args = get_args(field)
|
args = get_args(field)
|
||||||
actual = [name for name in args if name not in ignore and not name.startswith("_")]
|
actual = [name for name in args if name not in ignore and not name.startswith("_")]
|
||||||
assert set(arguments) == set(
|
assert set(arguments) == set(
|
||||||
|
@ -733,6 +733,73 @@ def test_should_query_filter_node_limit():
|
||||||
assert result.data == expected
|
assert result.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_by():
|
||||||
|
class ReporterType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Reporter
|
||||||
|
interfaces = (Node,)
|
||||||
|
|
||||||
|
class Query(ObjectType):
|
||||||
|
all_reporters = DjangoFilterConnectionField(
|
||||||
|
ReporterType, filterset_class=ReporterFilter
|
||||||
|
)
|
||||||
|
|
||||||
|
Reporter.objects.create(first_name="b")
|
||||||
|
Reporter.objects.create(first_name="a")
|
||||||
|
|
||||||
|
schema = Schema(query=Query)
|
||||||
|
query = """
|
||||||
|
query NodeFilteringQuery {
|
||||||
|
allReporters(orderBy: "-firstName") {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
firstName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
expected = {
|
||||||
|
"allReporters": {
|
||||||
|
"edges": [{"node": {"firstName": "b"}}, {"node": {"firstName": "a"}}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data == expected
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query NodeFilteringQuery {
|
||||||
|
allReporters(orderBy: "-first_name") {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
firstName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data == expected
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query NodeFilteringQuery {
|
||||||
|
allReporters(orderBy: "-firtsnaMe") {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
firstName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert result.errors
|
||||||
|
|
||||||
|
|
||||||
def test_order_by_is_perserved():
|
def test_order_by_is_perserved():
|
||||||
class ReporterType(DjangoObjectType):
|
class ReporterType(DjangoObjectType):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -939,7 +1006,7 @@ def test_integer_field_filter_type():
|
||||||
assert str(schema) == dedent(
|
assert str(schema) == dedent(
|
||||||
"""\
|
"""\
|
||||||
type Query {
|
type Query {
|
||||||
pets(before: String = null, after: String = null, first: Int = null, last: Int = null, age: Int = null): PetTypeConnection
|
pets(offset: Int = null, before: String = null, after: String = null, first: Int = null, last: Int = null, age: Int = null): PetTypeConnection
|
||||||
}
|
}
|
||||||
|
|
||||||
type PetTypeConnection {
|
type PetTypeConnection {
|
||||||
|
@ -1008,7 +1075,7 @@ def test_other_filter_types():
|
||||||
assert str(schema) == dedent(
|
assert str(schema) == dedent(
|
||||||
"""\
|
"""\
|
||||||
type Query {
|
type Query {
|
||||||
pets(before: String = null, after: String = null, first: Int = null, last: Int = null, age: Int = null, age_Isnull: Boolean = null, age_Lt: Int = null): PetTypeConnection
|
pets(offset: Int = null, before: String = null, after: String = null, first: Int = null, last: Int = null, age: Int = null, age_Isnull: Boolean = null, age_Lt: Int = null): PetTypeConnection
|
||||||
}
|
}
|
||||||
|
|
||||||
type PetTypeConnection {
|
type PetTypeConnection {
|
||||||
|
@ -1129,10 +1196,9 @@ def test_filter_filterset_based_on_mixin():
|
||||||
|
|
||||||
schema = Schema(query=Query)
|
schema = Schema(query=Query)
|
||||||
|
|
||||||
query = (
|
query = """
|
||||||
"""
|
query NodeFilteringQuery ($email: String!) {
|
||||||
query NodeFilteringQuery {
|
allArticles(viewer_Email_In: $email) {
|
||||||
allArticles(viewer_Email_In: "%s") {
|
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
headline
|
headline
|
||||||
|
@ -1144,8 +1210,6 @@ def test_filter_filterset_based_on_mixin():
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"""
|
"""
|
||||||
% reporter_1.email
|
|
||||||
)
|
|
||||||
|
|
||||||
expected = {
|
expected = {
|
||||||
"allArticles": {
|
"allArticles": {
|
||||||
|
@ -1160,7 +1224,7 @@ def test_filter_filterset_based_on_mixin():
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result = schema.execute(query)
|
result = schema.execute(query, variable_values={"email": reporter_1.email},)
|
||||||
|
|
||||||
assert not result.errors
|
assert not result.errors
|
||||||
assert result.data == expected
|
assert result.data == expected
|
||||||
|
|
139
graphene_django/filter/tests/test_in_filter.py
Normal file
139
graphene_django/filter/tests/test_in_filter.py
Normal file
|
@ -0,0 +1,139 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from graphene import ObjectType, Schema
|
||||||
|
from graphene.relay import Node
|
||||||
|
from graphene_django import DjangoObjectType
|
||||||
|
from graphene_django.tests.models import Pet
|
||||||
|
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
||||||
|
|
||||||
|
pytestmark = []
|
||||||
|
|
||||||
|
if DJANGO_FILTER_INSTALLED:
|
||||||
|
from graphene_django.filter import DjangoFilterConnectionField
|
||||||
|
else:
|
||||||
|
pytestmark.append(
|
||||||
|
pytest.mark.skipif(
|
||||||
|
True, reason="django_filters not installed or not compatible"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PetNode(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Pet
|
||||||
|
interfaces = (Node,)
|
||||||
|
filter_fields = {
|
||||||
|
"name": ["exact", "in"],
|
||||||
|
"age": ["exact", "in", "range"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Query(ObjectType):
|
||||||
|
pets = DjangoFilterConnectionField(PetNode)
|
||||||
|
|
||||||
|
|
||||||
|
def test_string_in_filter():
|
||||||
|
"""
|
||||||
|
Test in filter on a string field.
|
||||||
|
"""
|
||||||
|
Pet.objects.create(name="Brutus", age=12)
|
||||||
|
Pet.objects.create(name="Mimi", age=3)
|
||||||
|
Pet.objects.create(name="Jojo, the rabbit", age=3)
|
||||||
|
|
||||||
|
schema = Schema(query=Query)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
pets (name_In: ["Brutus", "Jojo, the rabbit"]) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["pets"]["edges"] == [
|
||||||
|
{"node": {"name": "Brutus"}},
|
||||||
|
{"node": {"name": "Jojo, the rabbit"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_int_in_filter():
|
||||||
|
"""
|
||||||
|
Test in filter on an integer field.
|
||||||
|
"""
|
||||||
|
Pet.objects.create(name="Brutus", age=12)
|
||||||
|
Pet.objects.create(name="Mimi", age=3)
|
||||||
|
Pet.objects.create(name="Jojo, the rabbit", age=3)
|
||||||
|
|
||||||
|
schema = Schema(query=Query)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
pets (age_In: [3]) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["pets"]["edges"] == [
|
||||||
|
{"node": {"name": "Mimi"}},
|
||||||
|
{"node": {"name": "Jojo, the rabbit"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
pets (age_In: [3, 12]) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["pets"]["edges"] == [
|
||||||
|
{"node": {"name": "Brutus"}},
|
||||||
|
{"node": {"name": "Mimi"}},
|
||||||
|
{"node": {"name": "Jojo, the rabbit"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_int_range_filter():
|
||||||
|
"""
|
||||||
|
Test in filter on an integer field.
|
||||||
|
"""
|
||||||
|
Pet.objects.create(name="Brutus", age=12)
|
||||||
|
Pet.objects.create(name="Mimi", age=8)
|
||||||
|
Pet.objects.create(name="Jojo, the rabbit", age=3)
|
||||||
|
Pet.objects.create(name="Picotin", age=5)
|
||||||
|
|
||||||
|
schema = Schema(query=Query)
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
pets (age_Range: [4, 9]) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["pets"]["edges"] == [
|
||||||
|
{"node": {"name": "Mimi"}},
|
||||||
|
{"node": {"name": "Picotin"}},
|
||||||
|
]
|
|
@ -1,4 +1,8 @@
|
||||||
|
from graphene import List
|
||||||
|
|
||||||
from django_filters.utils import get_model_field
|
from django_filters.utils import get_model_field
|
||||||
|
from django_filters.filters import Filter, BaseCSVFilter
|
||||||
|
|
||||||
from .filterset import custom_filterset_factory, setup_filterset
|
from .filterset import custom_filterset_factory, setup_filterset
|
||||||
|
|
||||||
|
|
||||||
|
@ -15,8 +19,11 @@ def get_filtering_args_from_filterset(filterset_class, type):
|
||||||
form_field = None
|
form_field = None
|
||||||
|
|
||||||
if name in filterset_class.declared_filters:
|
if name in filterset_class.declared_filters:
|
||||||
|
# Get the filter field from the explicitly declared filter
|
||||||
form_field = filter_field.field
|
form_field = filter_field.field
|
||||||
|
field = convert_form_field(form_field)
|
||||||
else:
|
else:
|
||||||
|
# Get the filter field with no explicit type declaration
|
||||||
model_field = get_model_field(model, filter_field.field_name)
|
model_field = get_model_field(model, filter_field.field_name)
|
||||||
filter_type = filter_field.lookup_expr
|
filter_type = filter_field.lookup_expr
|
||||||
if filter_type != "isnull" and hasattr(model_field, "formfield"):
|
if filter_type != "isnull" and hasattr(model_field, "formfield"):
|
||||||
|
@ -24,22 +31,63 @@ def get_filtering_args_from_filterset(filterset_class, type):
|
||||||
required=filter_field.extra.get("required", False)
|
required=filter_field.extra.get("required", False)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Fallback to field defined on filter if we can't get it from the
|
# Fallback to field defined on filter if we can't get it from the
|
||||||
# model field
|
# model field
|
||||||
if not form_field:
|
if not form_field:
|
||||||
form_field = filter_field.field
|
form_field = filter_field.field
|
||||||
|
|
||||||
field_type = convert_form_field(form_field).Argument()
|
field = convert_form_field(form_field)
|
||||||
|
|
||||||
|
if filter_type in ["in", "range"]:
|
||||||
|
# Replace CSV filters (`in`, `range`) argument type to be a list of the same type as the field.
|
||||||
|
# See comments in `replace_csv_filters` method for more details.
|
||||||
|
field = List(field.get_type())
|
||||||
|
|
||||||
|
field_type = field.Argument()
|
||||||
field_type.description = str(filter_field.label) if filter_field.label else None
|
field_type.description = str(filter_field.label) if filter_field.label else None
|
||||||
|
|
||||||
args[name] = field_type
|
args[name] = field_type
|
||||||
|
|
||||||
return args
|
return args
|
||||||
|
|
||||||
|
|
||||||
def get_filterset_class(filterset_class, **meta):
|
def get_filterset_class(filterset_class, **meta):
|
||||||
"""Get the class to be used as the FilterSet"""
|
"""
|
||||||
|
Get the class to be used as the FilterSet.
|
||||||
|
"""
|
||||||
if filterset_class:
|
if filterset_class:
|
||||||
# If were given a FilterSet class, then set it up and
|
# If were given a FilterSet class, then set it up.
|
||||||
# return it
|
graphene_filterset_class = setup_filterset(filterset_class)
|
||||||
return setup_filterset(filterset_class)
|
else:
|
||||||
return custom_filterset_factory(**meta)
|
# Otherwise create one.
|
||||||
|
graphene_filterset_class = custom_filterset_factory(**meta)
|
||||||
|
|
||||||
|
replace_csv_filters(graphene_filterset_class)
|
||||||
|
return graphene_filterset_class
|
||||||
|
|
||||||
|
|
||||||
|
def replace_csv_filters(filterset_class):
|
||||||
|
"""
|
||||||
|
Replace the "in" and "range" filters (that are not explicitly declared) to not be BaseCSVFilter (BaseInFilter, BaseRangeFilter) objects anymore
|
||||||
|
but regular Filter objects that simply use the input value as filter argument on the queryset.
|
||||||
|
|
||||||
|
This is because those BaseCSVFilter are expecting a string as input with comma separated value but with GraphQl we
|
||||||
|
can actually have a list as input and have a proper type verification of each value in the list.
|
||||||
|
|
||||||
|
See issue https://github.com/graphql-python/graphene-django/issues/1068.
|
||||||
|
"""
|
||||||
|
for name, filter_field in list(filterset_class.base_filters.items()):
|
||||||
|
filter_type = filter_field.lookup_expr
|
||||||
|
if (
|
||||||
|
filter_type in ["in", "range"]
|
||||||
|
and name not in filterset_class.declared_filters
|
||||||
|
):
|
||||||
|
assert isinstance(filter_field, BaseCSVFilter)
|
||||||
|
filterset_class.base_filters[name] = Filter(
|
||||||
|
field_name=filter_field.field_name,
|
||||||
|
lookup_expr=filter_field.lookup_expr,
|
||||||
|
label=filter_field.label,
|
||||||
|
method=filter_field.method,
|
||||||
|
exclude=filter_field.exclude,
|
||||||
|
**filter_field.extra
|
||||||
|
)
|
||||||
|
|
|
@ -11,8 +11,13 @@ from graphene.types.mutation import MutationOptions
|
||||||
# InputObjectType,
|
# InputObjectType,
|
||||||
# )
|
# )
|
||||||
from graphene.types.utils import yank_fields_from_attrs
|
from graphene.types.utils import yank_fields_from_attrs
|
||||||
|
from graphene_django.constants import MUTATION_ERRORS_FLAG
|
||||||
from graphene_django.registry import get_global_registry
|
from graphene_django.registry import get_global_registry
|
||||||
|
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import connection
|
||||||
|
|
||||||
from ..types import ErrorType
|
from ..types import ErrorType
|
||||||
from .converter import convert_form_field
|
from .converter import convert_form_field
|
||||||
|
|
||||||
|
@ -46,6 +51,7 @@ class BaseDjangoFormMutation(ClientIDMutation):
|
||||||
return cls.perform_mutate(form, info)
|
return cls.perform_mutate(form, info)
|
||||||
else:
|
else:
|
||||||
errors = ErrorType.from_errors(form.errors)
|
errors = ErrorType.from_errors(form.errors)
|
||||||
|
_set_errors_flag_to_context(info)
|
||||||
|
|
||||||
return cls(errors=errors, **form.data)
|
return cls(errors=errors, **form.data)
|
||||||
|
|
||||||
|
@ -170,6 +176,7 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
|
||||||
return cls.perform_mutate(form, info)
|
return cls.perform_mutate(form, info)
|
||||||
else:
|
else:
|
||||||
errors = ErrorType.from_errors(form.errors)
|
errors = ErrorType.from_errors(form.errors)
|
||||||
|
_set_errors_flag_to_context(info)
|
||||||
|
|
||||||
return cls(errors=errors)
|
return cls(errors=errors)
|
||||||
|
|
||||||
|
@ -178,3 +185,9 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
|
||||||
obj = form.save()
|
obj = form.save()
|
||||||
kwargs = {cls._meta.return_field_name: obj}
|
kwargs = {cls._meta.return_field_name: obj}
|
||||||
return cls(errors=[], **kwargs)
|
return cls(errors=[], **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_errors_flag_to_context(info):
|
||||||
|
# This is not ideal but necessary to keep the response errors empty
|
||||||
|
if info and info.context:
|
||||||
|
setattr(info.context, MUTATION_ERRORS_FLAG, True)
|
||||||
|
|
|
@ -66,10 +66,6 @@ def test_should_choice_convert_string():
|
||||||
assert_conversion(forms.ChoiceField, String)
|
assert_conversion(forms.ChoiceField, String)
|
||||||
|
|
||||||
|
|
||||||
def test_should_multiple_choice_convert_list():
|
|
||||||
assert_conversion(forms.MultipleChoiceField, List)
|
|
||||||
|
|
||||||
|
|
||||||
def test_should_base_field_convert_string():
|
def test_should_base_field_convert_string():
|
||||||
assert_conversion(forms.Field, String)
|
assert_conversion(forms.Field, String)
|
||||||
|
|
||||||
|
@ -105,7 +101,14 @@ def test_should_decimal_convert_float():
|
||||||
assert_conversion(forms.DecimalField, Float)
|
assert_conversion(forms.DecimalField, Float)
|
||||||
|
|
||||||
|
|
||||||
def test_should_multiple_choice_convert_connectionorlist():
|
def test_should_multiple_choice_convert_list():
|
||||||
|
field = forms.MultipleChoiceField()
|
||||||
|
graphene_type = convert_form_field(field)
|
||||||
|
assert isinstance(graphene_type, List)
|
||||||
|
assert graphene_type.of_type == String
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_model_multiple_choice_convert_connectionorlist():
|
||||||
field = forms.ModelMultipleChoiceField(queryset=None)
|
field = forms.ModelMultipleChoiceField(queryset=None)
|
||||||
graphene_type = convert_form_field(field)
|
graphene_type = convert_form_field(field)
|
||||||
assert isinstance(graphene_type, List)
|
assert isinstance(graphene_type, List)
|
||||||
|
|
|
@ -5,21 +5,13 @@ from py.test import raises
|
||||||
|
|
||||||
from graphene import Field, ObjectType, Schema, String
|
from graphene import Field, ObjectType, Schema, String
|
||||||
from graphene_django import DjangoObjectType
|
from graphene_django import DjangoObjectType
|
||||||
|
from graphene_django.tests.forms import PetForm
|
||||||
from graphene_django.tests.models import Pet
|
from graphene_django.tests.models import Pet
|
||||||
|
from graphene_django.tests.mutations import PetMutation
|
||||||
|
|
||||||
from ..mutation import DjangoFormMutation, DjangoModelFormMutation
|
from ..mutation import DjangoFormMutation, DjangoModelFormMutation
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture()
|
|
||||||
def pet_type():
|
|
||||||
class PetType(DjangoObjectType):
|
|
||||||
class Meta:
|
|
||||||
model = Pet
|
|
||||||
fields = "__all__"
|
|
||||||
|
|
||||||
return PetType
|
|
||||||
|
|
||||||
|
|
||||||
class MyForm(forms.Form):
|
class MyForm(forms.Form):
|
||||||
text = forms.CharField()
|
text = forms.CharField()
|
||||||
|
|
||||||
|
@ -33,18 +25,6 @@ class MyForm(forms.Form):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PetForm(forms.ModelForm):
|
|
||||||
class Meta:
|
|
||||||
model = Pet
|
|
||||||
fields = "__all__"
|
|
||||||
|
|
||||||
def clean_age(self):
|
|
||||||
age = self.cleaned_data["age"]
|
|
||||||
if age >= 99:
|
|
||||||
raise ValidationError("Too old")
|
|
||||||
return age
|
|
||||||
|
|
||||||
|
|
||||||
def test_needs_form_class():
|
def test_needs_form_class():
|
||||||
with raises(Exception) as exc:
|
with raises(Exception) as exc:
|
||||||
|
|
||||||
|
@ -70,11 +50,18 @@ def test_has_input_fields():
|
||||||
assert "text" in MyMutation.Input._meta.fields
|
assert "text" in MyMutation.Input._meta.fields
|
||||||
|
|
||||||
|
|
||||||
def test_mutation_error_camelcased(pet_type, graphene_settings):
|
def test_mutation_error_camelcased(graphene_settings):
|
||||||
class ExtraPetForm(PetForm):
|
class ExtraPetForm(PetForm):
|
||||||
test_field = forms.CharField(required=True)
|
test_field = forms.CharField(required=True)
|
||||||
|
|
||||||
|
class PetType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Pet
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
class PetMutation(DjangoModelFormMutation):
|
class PetMutation(DjangoModelFormMutation):
|
||||||
|
pet = Field(PetType)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
form_class = ExtraPetForm
|
form_class = ExtraPetForm
|
||||||
|
|
||||||
|
@ -146,21 +133,13 @@ def test_form_valid_input():
|
||||||
assert result.data["myMutation"]["text"] == "VALID_INPUT"
|
assert result.data["myMutation"]["text"] == "VALID_INPUT"
|
||||||
|
|
||||||
|
|
||||||
def test_default_meta_fields(pet_type):
|
def test_default_meta_fields():
|
||||||
class PetMutation(DjangoModelFormMutation):
|
|
||||||
class Meta:
|
|
||||||
form_class = PetForm
|
|
||||||
|
|
||||||
assert PetMutation._meta.model is Pet
|
assert PetMutation._meta.model is Pet
|
||||||
assert PetMutation._meta.return_field_name == "pet"
|
assert PetMutation._meta.return_field_name == "pet"
|
||||||
assert "pet" in PetMutation._meta.fields
|
assert "pet" in PetMutation._meta.fields
|
||||||
|
|
||||||
|
|
||||||
def test_default_input_meta_fields(pet_type):
|
def test_default_input_meta_fields():
|
||||||
class PetMutation(DjangoModelFormMutation):
|
|
||||||
class Meta:
|
|
||||||
form_class = PetForm
|
|
||||||
|
|
||||||
assert PetMutation._meta.model is Pet
|
assert PetMutation._meta.model is Pet
|
||||||
assert PetMutation._meta.return_field_name == "pet"
|
assert PetMutation._meta.return_field_name == "pet"
|
||||||
assert "name" in PetMutation.Input._meta.fields
|
assert "name" in PetMutation.Input._meta.fields
|
||||||
|
@ -168,8 +147,15 @@ def test_default_input_meta_fields(pet_type):
|
||||||
assert "id" in PetMutation.Input._meta.fields
|
assert "id" in PetMutation.Input._meta.fields
|
||||||
|
|
||||||
|
|
||||||
def test_exclude_fields_input_meta_fields(pet_type):
|
def test_exclude_fields_input_meta_fields():
|
||||||
|
class PetType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Pet
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
class PetMutation(DjangoModelFormMutation):
|
class PetMutation(DjangoModelFormMutation):
|
||||||
|
pet = Field(PetType)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
form_class = PetForm
|
form_class = PetForm
|
||||||
exclude_fields = ["id"]
|
exclude_fields = ["id"]
|
||||||
|
@ -182,8 +168,15 @@ def test_exclude_fields_input_meta_fields(pet_type):
|
||||||
assert "id" not in PetMutation.Input._meta.fields
|
assert "id" not in PetMutation.Input._meta.fields
|
||||||
|
|
||||||
|
|
||||||
def test_custom_return_field_name(pet_type):
|
def test_custom_return_field_name():
|
||||||
|
class PetType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Pet
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
class PetMutation(DjangoModelFormMutation):
|
class PetMutation(DjangoModelFormMutation):
|
||||||
|
pet = Field(PetType)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
form_class = PetForm
|
form_class = PetForm
|
||||||
model = Pet
|
model = Pet
|
||||||
|
@ -194,13 +187,7 @@ def test_custom_return_field_name(pet_type):
|
||||||
assert "animal" in PetMutation._meta.fields
|
assert "animal" in PetMutation._meta.fields
|
||||||
|
|
||||||
|
|
||||||
def test_model_form_mutation_mutate_existing(pet_type):
|
def test_model_form_mutation_mutate_existing():
|
||||||
class PetMutation(DjangoModelFormMutation):
|
|
||||||
pet = Field(pet_type)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
form_class = PetForm
|
|
||||||
|
|
||||||
class Mutation(ObjectType):
|
class Mutation(ObjectType):
|
||||||
pet_mutation = PetMutation.Field()
|
pet_mutation = PetMutation.Field()
|
||||||
|
|
||||||
|
@ -229,13 +216,7 @@ def test_model_form_mutation_mutate_existing(pet_type):
|
||||||
assert pet.name == "Mia"
|
assert pet.name == "Mia"
|
||||||
|
|
||||||
|
|
||||||
def test_model_form_mutation_creates_new(pet_type):
|
def test_model_form_mutation_creates_new():
|
||||||
class PetMutation(DjangoModelFormMutation):
|
|
||||||
pet = Field(pet_type)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
form_class = PetForm
|
|
||||||
|
|
||||||
class Mutation(ObjectType):
|
class Mutation(ObjectType):
|
||||||
pet_mutation = PetMutation.Field()
|
pet_mutation = PetMutation.Field()
|
||||||
|
|
||||||
|
@ -265,13 +246,7 @@ def test_model_form_mutation_creates_new(pet_type):
|
||||||
assert pet.age == 10
|
assert pet.age == 10
|
||||||
|
|
||||||
|
|
||||||
def test_model_form_mutation_invalid_input(pet_type):
|
def test_model_form_mutation_invalid_input():
|
||||||
class PetMutation(DjangoModelFormMutation):
|
|
||||||
pet = Field(pet_type)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
form_class = PetForm
|
|
||||||
|
|
||||||
class Mutation(ObjectType):
|
class Mutation(ObjectType):
|
||||||
pet_mutation = PetMutation.Field()
|
pet_mutation = PetMutation.Field()
|
||||||
|
|
||||||
|
@ -301,11 +276,7 @@ def test_model_form_mutation_invalid_input(pet_type):
|
||||||
assert Pet.objects.count() == 0
|
assert Pet.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
def test_model_form_mutation_mutate_invalid_form(pet_type):
|
def test_model_form_mutation_mutate_invalid_form():
|
||||||
class PetMutation(DjangoModelFormMutation):
|
|
||||||
class Meta:
|
|
||||||
form_class = PetForm
|
|
||||||
|
|
||||||
result = PetMutation.mutate_and_get_payload(None, None)
|
result = PetMutation.mutate_and_get_payload(None, None)
|
||||||
|
|
||||||
# A pet was not created
|
# A pet was not created
|
||||||
|
@ -317,3 +288,98 @@ def test_model_form_mutation_mutate_invalid_form(pet_type):
|
||||||
assert result.errors[1].messages == ["This field is required."]
|
assert result.errors[1].messages == ["This field is required."]
|
||||||
assert "age" in fields_w_error
|
assert "age" in fields_w_error
|
||||||
assert "name" in fields_w_error
|
assert "name" in fields_w_error
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_form_mutation_multiple_creation_valid():
|
||||||
|
class Mutation(ObjectType):
|
||||||
|
pet_mutation = PetMutation.Field()
|
||||||
|
|
||||||
|
schema = Schema(query=MockQuery, mutation=Mutation)
|
||||||
|
|
||||||
|
result = schema.execute(
|
||||||
|
"""
|
||||||
|
mutation PetMutations {
|
||||||
|
petMutation1: petMutation(input: { name: "Mia", age: 10 }) {
|
||||||
|
pet {
|
||||||
|
name
|
||||||
|
age
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
petMutation2: petMutation(input: { name: "Enzo", age: 0 }) {
|
||||||
|
pet {
|
||||||
|
name
|
||||||
|
age
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert result.errors is None
|
||||||
|
assert result.data["petMutation1"]["pet"] == {"name": "Mia", "age": 10}
|
||||||
|
assert result.data["petMutation2"]["pet"] == {"name": "Enzo", "age": 0}
|
||||||
|
|
||||||
|
assert Pet.objects.count() == 2
|
||||||
|
|
||||||
|
pet1 = Pet.objects.first()
|
||||||
|
assert pet1.name == "Mia"
|
||||||
|
assert pet1.age == 10
|
||||||
|
|
||||||
|
pet2 = Pet.objects.last()
|
||||||
|
assert pet2.name == "Enzo"
|
||||||
|
assert pet2.age == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_form_mutation_multiple_creation_invalid():
|
||||||
|
class Mutation(ObjectType):
|
||||||
|
pet_mutation = PetMutation.Field()
|
||||||
|
|
||||||
|
schema = Schema(query=MockQuery, mutation=Mutation)
|
||||||
|
|
||||||
|
result = schema.execute(
|
||||||
|
"""
|
||||||
|
mutation PetMutations {
|
||||||
|
petMutation1: petMutation(input: { name: "Mia", age: 99 }) {
|
||||||
|
pet {
|
||||||
|
name
|
||||||
|
age
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
petMutation2: petMutation(input: { name: "Enzo", age: 0 }) {
|
||||||
|
pet {
|
||||||
|
name
|
||||||
|
age
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
assert result.errors is None
|
||||||
|
|
||||||
|
assert result.data["petMutation1"]["pet"] is None
|
||||||
|
assert result.data["petMutation1"]["errors"] == [
|
||||||
|
{"field": "age", "messages": ["Too old"]}
|
||||||
|
]
|
||||||
|
|
||||||
|
assert result.data["petMutation2"]["pet"] == {"name": "Enzo", "age": 0}
|
||||||
|
|
||||||
|
assert Pet.objects.count() == 1
|
||||||
|
|
||||||
|
pet = Pet.objects.get()
|
||||||
|
assert pet.name == "Enzo"
|
||||||
|
assert pet.age == 0
|
||||||
|
|
|
@ -60,8 +60,10 @@ class Command(CommandArguments):
|
||||||
|
|
||||||
def get_schema(self, schema, out, indent):
|
def get_schema(self, schema, out, indent):
|
||||||
schema_dict = {"data": schema.introspect()}
|
schema_dict = {"data": schema.introspect()}
|
||||||
if out == "-":
|
if out == "-" or out == "-.json":
|
||||||
self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True))
|
self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True))
|
||||||
|
elif out == "-.graphql":
|
||||||
|
self.stdout.write(print_schema(schema))
|
||||||
else:
|
else:
|
||||||
# Determine format
|
# Determine format
|
||||||
_, file_extension = os.path.splitext(out)
|
_, file_extension = os.path.splitext(out)
|
||||||
|
|
|
@ -26,6 +26,7 @@ def fields_for_serializer(
|
||||||
exclude_fields,
|
exclude_fields,
|
||||||
is_input=False,
|
is_input=False,
|
||||||
convert_choices_to_enum=True,
|
convert_choices_to_enum=True,
|
||||||
|
lookup_field=None,
|
||||||
):
|
):
|
||||||
fields = OrderedDict()
|
fields = OrderedDict()
|
||||||
for name, field in serializer.fields.items():
|
for name, field in serializer.fields.items():
|
||||||
|
@ -35,7 +36,9 @@ def fields_for_serializer(
|
||||||
name in exclude_fields,
|
name in exclude_fields,
|
||||||
field.write_only
|
field.write_only
|
||||||
and not is_input, # don't show write_only fields in Query
|
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
|
field.read_only
|
||||||
|
and is_input
|
||||||
|
and lookup_field != name, # don't show read_only fields in Input
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -66,6 +69,7 @@ class SerializerMutation(ClientIDMutation):
|
||||||
only_fields=(),
|
only_fields=(),
|
||||||
exclude_fields=(),
|
exclude_fields=(),
|
||||||
convert_choices_to_enum=True,
|
convert_choices_to_enum=True,
|
||||||
|
_meta=None,
|
||||||
**options
|
**options
|
||||||
):
|
):
|
||||||
|
|
||||||
|
@ -90,6 +94,7 @@ class SerializerMutation(ClientIDMutation):
|
||||||
exclude_fields,
|
exclude_fields,
|
||||||
is_input=True,
|
is_input=True,
|
||||||
convert_choices_to_enum=convert_choices_to_enum,
|
convert_choices_to_enum=convert_choices_to_enum,
|
||||||
|
lookup_field=lookup_field,
|
||||||
)
|
)
|
||||||
output_fields = fields_for_serializer(
|
output_fields = fields_for_serializer(
|
||||||
serializer,
|
serializer,
|
||||||
|
@ -97,9 +102,11 @@ class SerializerMutation(ClientIDMutation):
|
||||||
exclude_fields,
|
exclude_fields,
|
||||||
is_input=False,
|
is_input=False,
|
||||||
convert_choices_to_enum=convert_choices_to_enum,
|
convert_choices_to_enum=convert_choices_to_enum,
|
||||||
|
lookup_field=lookup_field,
|
||||||
)
|
)
|
||||||
|
|
||||||
_meta = SerializerMutationOptions(cls)
|
if not _meta:
|
||||||
|
_meta = SerializerMutationOptions(cls)
|
||||||
_meta.lookup_field = lookup_field
|
_meta.lookup_field = lookup_field
|
||||||
_meta.model_operations = model_operations
|
_meta.model_operations = model_operations
|
||||||
_meta.serializer_class = serializer_class
|
_meta.serializer_class = serializer_class
|
||||||
|
|
|
@ -145,17 +145,20 @@ def test_write_only_field_using_extra_kwargs():
|
||||||
|
|
||||||
def test_read_only_fields():
|
def test_read_only_fields():
|
||||||
class ReadOnlyFieldModelSerializer(serializers.ModelSerializer):
|
class ReadOnlyFieldModelSerializer(serializers.ModelSerializer):
|
||||||
|
id = serializers.CharField(read_only=True)
|
||||||
cool_name = serializers.CharField(read_only=True)
|
cool_name = serializers.CharField(read_only=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = MyFakeModelWithPassword
|
model = MyFakeModelWithPassword
|
||||||
fields = ["cool_name", "password"]
|
lookup_field = "id"
|
||||||
|
fields = ["id", "cool_name", "password"]
|
||||||
|
|
||||||
class MyMutation(SerializerMutation):
|
class MyMutation(SerializerMutation):
|
||||||
class Meta:
|
class Meta:
|
||||||
serializer_class = ReadOnlyFieldModelSerializer
|
serializer_class = ReadOnlyFieldModelSerializer
|
||||||
|
|
||||||
assert "password" in MyMutation.Input._meta.fields
|
assert "password" in MyMutation.Input._meta.fields
|
||||||
|
assert "id" in MyMutation.Input._meta.fields
|
||||||
assert (
|
assert (
|
||||||
"cool_name" not in MyMutation.Input._meta.fields
|
"cool_name" not in MyMutation.Input._meta.fields
|
||||||
), "'cool_name' is read_only field and shouldn't be on arguments"
|
), "'cool_name' is read_only field and shouldn't be on arguments"
|
||||||
|
|
|
@ -44,6 +44,7 @@ DEFAULTS = {
|
||||||
# This sets headerEditorEnabled GraphiQL option, for details go to
|
# This sets headerEditorEnabled GraphiQL option, for details go to
|
||||||
# https://github.com/graphql/graphiql/tree/main/packages/graphiql#options
|
# https://github.com/graphql/graphiql/tree/main/packages/graphiql#options
|
||||||
"GRAPHIQL_HEADER_EDITOR_ENABLED": True,
|
"GRAPHIQL_HEADER_EDITOR_ENABLED": True,
|
||||||
|
"ATOMIC_MUTATIONS": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
|
16
graphene_django/tests/forms.py
Normal file
16
graphene_django/tests/forms.py
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
from django import forms
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
from .models import Pet
|
||||||
|
|
||||||
|
|
||||||
|
class PetForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Pet
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
def clean_age(self):
|
||||||
|
age = self.cleaned_data["age"]
|
||||||
|
if age >= 99:
|
||||||
|
raise ValidationError("Too old")
|
||||||
|
return age
|
18
graphene_django/tests/mutations.py
Normal file
18
graphene_django/tests/mutations.py
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
from graphene import Field
|
||||||
|
|
||||||
|
from graphene_django.forms.mutation import DjangoFormMutation, DjangoModelFormMutation
|
||||||
|
|
||||||
|
from .forms import PetForm
|
||||||
|
from .types import PetType
|
||||||
|
|
||||||
|
|
||||||
|
class PetFormMutation(DjangoFormMutation):
|
||||||
|
class Meta:
|
||||||
|
form_class = PetForm
|
||||||
|
|
||||||
|
|
||||||
|
class PetMutation(DjangoModelFormMutation):
|
||||||
|
pet = Field(PetType)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
form_class = PetForm
|
|
@ -1,6 +1,8 @@
|
||||||
import graphene
|
import graphene
|
||||||
from graphene import ObjectType, Schema
|
from graphene import ObjectType, Schema
|
||||||
|
|
||||||
|
from .mutations import PetFormMutation, PetMutation
|
||||||
|
|
||||||
|
|
||||||
class QueryRoot(ObjectType):
|
class QueryRoot(ObjectType):
|
||||||
|
|
||||||
|
@ -19,6 +21,8 @@ class QueryRoot(ObjectType):
|
||||||
|
|
||||||
|
|
||||||
class MutationRoot(ObjectType):
|
class MutationRoot(ObjectType):
|
||||||
|
pet_form_mutation = PetFormMutation.Field()
|
||||||
|
pet_mutation = PetMutation.Field()
|
||||||
write_test = graphene.Field(QueryRoot)
|
write_test = graphene.Field(QueryRoot)
|
||||||
|
|
||||||
def resolve_write_test(self, info):
|
def resolve_write_test(self, info):
|
||||||
|
|
|
@ -242,6 +242,10 @@ def test_should_float_convert_float():
|
||||||
assert_conversion(models.FloatField, graphene.Float)
|
assert_conversion(models.FloatField, graphene.Float)
|
||||||
|
|
||||||
|
|
||||||
|
def test_should_float_convert_decimal():
|
||||||
|
assert_conversion(models.DecimalField, graphene.Decimal)
|
||||||
|
|
||||||
|
|
||||||
def test_should_manytomany_convert_connectionorlist():
|
def test_should_manytomany_convert_connectionorlist():
|
||||||
registry = Registry()
|
registry = Registry()
|
||||||
dynamic_field = convert_django_field(Reporter._meta.local_many_to_many[0], registry)
|
dynamic_field = convert_django_field(Reporter._meta.local_many_to_many[0], registry)
|
||||||
|
|
|
@ -75,6 +75,39 @@ class TestDjangoListField:
|
||||||
"reporters": [{"firstName": "Tara"}, {"firstName": "Debra"}]
|
"reporters": [{"firstName": "Tara"}, {"firstName": "Debra"}]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def test_list_field_queryset_is_not_cached(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data == {"reporters": []}
|
||||||
|
|
||||||
|
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):
|
def test_override_resolver(self):
|
||||||
class Reporter(DjangoObjectType):
|
class Reporter(DjangoObjectType):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -267,7 +300,7 @@ class TestDjangoListField:
|
||||||
result = schema.execute(query)
|
result = schema.execute(query)
|
||||||
|
|
||||||
assert not result.errors
|
assert not result.errors
|
||||||
assert result.data == {"reporters": [{"firstName": "Tara"},]}
|
assert result.data == {"reporters": [{"firstName": "Tara"}]}
|
||||||
|
|
||||||
def test_resolve_list(self):
|
def test_resolve_list(self):
|
||||||
"""Resolving a plain list should work (and not call get_queryset)"""
|
"""Resolving a plain list should work (and not call get_queryset)"""
|
||||||
|
@ -314,7 +347,7 @@ class TestDjangoListField:
|
||||||
result = schema.execute(query)
|
result = schema.execute(query)
|
||||||
|
|
||||||
assert not result.errors
|
assert not result.errors
|
||||||
assert result.data == {"reporters": [{"firstName": "Debra"},]}
|
assert result.data == {"reporters": [{"firstName": "Debra"}]}
|
||||||
|
|
||||||
def test_get_queryset_foreign_key(self):
|
def test_get_queryset_foreign_key(self):
|
||||||
class Article(DjangoObjectType):
|
class Article(DjangoObjectType):
|
||||||
|
@ -371,7 +404,7 @@ class TestDjangoListField:
|
||||||
assert not result.errors
|
assert not result.errors
|
||||||
assert result.data == {
|
assert result.data == {
|
||||||
"reporters": [
|
"reporters": [
|
||||||
{"firstName": "Tara", "articles": [{"headline": "Amazing news"},],},
|
{"firstName": "Tara", "articles": [{"headline": "Amazing news"}]},
|
||||||
{"firstName": "Debra", "articles": []},
|
{"firstName": "Debra", "articles": []},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -1241,6 +1241,103 @@ def test_should_have_next_page(graphene_settings):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestBackwardPagination:
|
||||||
|
def setup_schema(self, graphene_settings, max_limit):
|
||||||
|
graphene_settings.RELAY_CONNECTION_MAX_LIMIT = max_limit
|
||||||
|
reporters = [Reporter(**kwargs) for kwargs in REPORTERS]
|
||||||
|
Reporter.objects.bulk_create(reporters)
|
||||||
|
|
||||||
|
class ReporterType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Reporter
|
||||||
|
interfaces = (Node,)
|
||||||
|
|
||||||
|
class Query(graphene.ObjectType):
|
||||||
|
all_reporters = DjangoConnectionField(ReporterType)
|
||||||
|
|
||||||
|
schema = graphene.Schema(query=Query)
|
||||||
|
return schema
|
||||||
|
|
||||||
|
def do_queries(self, schema):
|
||||||
|
# Simply last 3
|
||||||
|
query_last = """
|
||||||
|
query {
|
||||||
|
allReporters(last: 3) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
firstName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = schema.execute(query_last)
|
||||||
|
assert not result.errors
|
||||||
|
assert len(result.data["allReporters"]["edges"]) == 3
|
||||||
|
assert [
|
||||||
|
e["node"]["firstName"] for e in result.data["allReporters"]["edges"]
|
||||||
|
] == ["First 3", "First 4", "First 5"]
|
||||||
|
|
||||||
|
# Use a combination of first and last
|
||||||
|
query_first_and_last = """
|
||||||
|
query {
|
||||||
|
allReporters(first: 4, last: 3) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
firstName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = schema.execute(query_first_and_last)
|
||||||
|
assert not result.errors
|
||||||
|
assert len(result.data["allReporters"]["edges"]) == 3
|
||||||
|
assert [
|
||||||
|
e["node"]["firstName"] for e in result.data["allReporters"]["edges"]
|
||||||
|
] == ["First 1", "First 2", "First 3"]
|
||||||
|
|
||||||
|
# Use a combination of first and last and after
|
||||||
|
query_first_last_and_after = """
|
||||||
|
query queryAfter($after: String) {
|
||||||
|
allReporters(first: 4, last: 3, after: $after) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
firstName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
after = base64.b64encode(b"arrayconnection:0").decode()
|
||||||
|
result = schema.execute(
|
||||||
|
query_first_last_and_after, variable_values=dict(after=after)
|
||||||
|
)
|
||||||
|
assert not result.errors
|
||||||
|
assert len(result.data["allReporters"]["edges"]) == 3
|
||||||
|
assert [
|
||||||
|
e["node"]["firstName"] for e in result.data["allReporters"]["edges"]
|
||||||
|
] == ["First 2", "First 3", "First 4"]
|
||||||
|
|
||||||
|
def test_should_query(self, graphene_settings):
|
||||||
|
"""
|
||||||
|
Backward pagination should work as expected
|
||||||
|
"""
|
||||||
|
schema = self.setup_schema(graphene_settings, max_limit=100)
|
||||||
|
self.do_queries(schema)
|
||||||
|
|
||||||
|
def test_should_query_with_low_max_limit(self, graphene_settings):
|
||||||
|
"""
|
||||||
|
When doing backward pagination (using last) in combination with a max limit higher than the number of objects
|
||||||
|
we should really retrieve the last ones.
|
||||||
|
"""
|
||||||
|
schema = self.setup_schema(graphene_settings, max_limit=4)
|
||||||
|
self.do_queries(schema)
|
||||||
|
|
||||||
|
|
||||||
def test_should_preserve_prefetch_related(django_assert_num_queries):
|
def test_should_preserve_prefetch_related(django_assert_num_queries):
|
||||||
class ReporterType(DjangoObjectType):
|
class ReporterType(DjangoObjectType):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -1348,3 +1445,144 @@ def test_should_preserve_annotations():
|
||||||
}
|
}
|
||||||
assert result.data == expected, str(result.data)
|
assert result.data == expected, str(result.data)
|
||||||
assert not result.errors
|
assert not result.errors
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection_should_enable_offset_filtering():
|
||||||
|
Reporter.objects.create(first_name="John", last_name="Doe")
|
||||||
|
Reporter.objects.create(first_name="Some", last_name="Guy")
|
||||||
|
|
||||||
|
class ReporterType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Reporter
|
||||||
|
interfaces = (Node,)
|
||||||
|
|
||||||
|
class Query(graphene.ObjectType):
|
||||||
|
all_reporters = DjangoConnectionField(ReporterType)
|
||||||
|
|
||||||
|
schema = graphene.Schema(query=Query)
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
allReporters(first: 1, offset: 1) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
firstName
|
||||||
|
lastName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
expected = {
|
||||||
|
"allReporters": {"edges": [{"node": {"firstName": "Some", "lastName": "Guy"}},]}
|
||||||
|
}
|
||||||
|
assert result.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection_should_enable_offset_filtering_higher_than_max_limit(
|
||||||
|
graphene_settings,
|
||||||
|
):
|
||||||
|
graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 2
|
||||||
|
Reporter.objects.create(first_name="John", last_name="Doe")
|
||||||
|
Reporter.objects.create(first_name="Some", last_name="Guy")
|
||||||
|
Reporter.objects.create(first_name="Jane", last_name="Roe")
|
||||||
|
Reporter.objects.create(first_name="Some", last_name="Lady")
|
||||||
|
|
||||||
|
class ReporterType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Reporter
|
||||||
|
interfaces = (Node,)
|
||||||
|
|
||||||
|
class Query(graphene.ObjectType):
|
||||||
|
all_reporters = DjangoConnectionField(ReporterType)
|
||||||
|
|
||||||
|
schema = graphene.Schema(query=Query)
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
allReporters(first: 1, offset: 3) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
firstName
|
||||||
|
lastName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
expected = {
|
||||||
|
"allReporters": {
|
||||||
|
"edges": [{"node": {"firstName": "Some", "lastName": "Lady"}},]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert result.data == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection_should_forbid_offset_filtering_with_before():
|
||||||
|
class ReporterType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Reporter
|
||||||
|
interfaces = (Node,)
|
||||||
|
|
||||||
|
class Query(graphene.ObjectType):
|
||||||
|
all_reporters = DjangoConnectionField(ReporterType)
|
||||||
|
|
||||||
|
schema = graphene.Schema(query=Query)
|
||||||
|
query = """
|
||||||
|
query ReporterPromiseConnectionQuery ($before: String) {
|
||||||
|
allReporters(first: 1, before: $before, offset: 1) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
firstName
|
||||||
|
lastName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
before = base64.b64encode(b"arrayconnection:2").decode()
|
||||||
|
result = schema.execute(query, variable_values=dict(before=before))
|
||||||
|
expected_error = "You can't provide a `before` value at the same time as an `offset` value to properly paginate the `allReporters` connection."
|
||||||
|
assert len(result.errors) == 1
|
||||||
|
assert result.errors[0].message == expected_error
|
||||||
|
|
||||||
|
|
||||||
|
def test_connection_should_allow_offset_filtering_with_after():
|
||||||
|
Reporter.objects.create(first_name="John", last_name="Doe")
|
||||||
|
Reporter.objects.create(first_name="Some", last_name="Guy")
|
||||||
|
Reporter.objects.create(first_name="Jane", last_name="Roe")
|
||||||
|
Reporter.objects.create(first_name="Some", last_name="Lady")
|
||||||
|
|
||||||
|
class ReporterType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Reporter
|
||||||
|
interfaces = (Node,)
|
||||||
|
|
||||||
|
class Query(graphene.ObjectType):
|
||||||
|
all_reporters = DjangoConnectionField(ReporterType)
|
||||||
|
|
||||||
|
schema = graphene.Schema(query=Query)
|
||||||
|
query = """
|
||||||
|
query ReporterPromiseConnectionQuery ($after: String) {
|
||||||
|
allReporters(first: 1, after: $after, offset: 1) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
firstName
|
||||||
|
lastName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
after = base64.b64encode(b"arrayconnection:0").decode()
|
||||||
|
result = schema.execute(query, variable_values=dict(after=after))
|
||||||
|
assert not result.errors
|
||||||
|
expected = {
|
||||||
|
"allReporters": {"edges": [{"node": {"firstName": "Jane", "lastName": "Roe"}},]}
|
||||||
|
}
|
||||||
|
assert result.data == expected
|
||||||
|
|
|
@ -183,7 +183,7 @@ def test_schema_representation():
|
||||||
pets: [Reporter!]!
|
pets: [Reporter!]!
|
||||||
aChoice: TestsReporterAChoiceChoices
|
aChoice: TestsReporterAChoiceChoices
|
||||||
reporterType: TestsReporterReporterTypeChoices
|
reporterType: TestsReporterReporterTypeChoices
|
||||||
articles(before: String = null, after: String = null, first: Int = null, last: Int = null): ArticleConnection!
|
articles(offset: Int = null, before: String = null, after: String = null, first: Int = null, last: Int = null): ArticleConnection!
|
||||||
}
|
}
|
||||||
|
|
||||||
\"""An enumeration.\"""
|
\"""An enumeration.\"""
|
||||||
|
|
|
@ -2,6 +2,14 @@ import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
from mock import patch
|
||||||
|
|
||||||
|
from django.db import connection
|
||||||
|
|
||||||
|
from graphene_django.settings import graphene_settings
|
||||||
|
|
||||||
|
from .models import Pet
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from urllib import urlencode
|
from urllib import urlencode
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
@ -562,3 +570,265 @@ def test_passes_request_into_context_request(client):
|
||||||
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response_json(response) == {"data": {"request": "testing"}}
|
assert response_json(response) == {"data": {"request": "testing"}}
|
||||||
|
|
||||||
|
|
||||||
|
@patch("graphene_django.settings.graphene_settings.ATOMIC_MUTATIONS", False)
|
||||||
|
@patch.dict(
|
||||||
|
connection.settings_dict, {"ATOMIC_MUTATIONS": False, "ATOMIC_REQUESTS": True}
|
||||||
|
)
|
||||||
|
def test_form_mutation_multiple_creation_invalid_atomic_request(client):
|
||||||
|
query = """
|
||||||
|
mutation PetMutations {
|
||||||
|
petFormMutation1: petFormMutation(input: { name: "Mia", age: 99 }) {
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
petFormMutation2: petFormMutation(input: { name: "Enzo", age: 0 }) {
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = client.post(url_string(query=query))
|
||||||
|
content = response_json(response)
|
||||||
|
|
||||||
|
assert "errors" not in content
|
||||||
|
|
||||||
|
assert content["data"]["petFormMutation1"]["errors"] == [
|
||||||
|
{"field": "age", "messages": ["Too old"]}
|
||||||
|
]
|
||||||
|
|
||||||
|
assert content["data"]["petFormMutation2"]["errors"] == []
|
||||||
|
|
||||||
|
assert Pet.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@patch("graphene_django.settings.graphene_settings.ATOMIC_MUTATIONS", False)
|
||||||
|
@patch.dict(
|
||||||
|
connection.settings_dict, {"ATOMIC_MUTATIONS": True, "ATOMIC_REQUESTS": False}
|
||||||
|
)
|
||||||
|
def test_form_mutation_multiple_creation_invalid_atomic_mutation_1(client):
|
||||||
|
query = """
|
||||||
|
mutation PetMutations {
|
||||||
|
petFormMutation1: petFormMutation(input: { name: "Mia", age: 99 }) {
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
petFormMutation2: petFormMutation(input: { name: "Enzo", age: 0 }) {
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = client.post(url_string(query=query))
|
||||||
|
content = response_json(response)
|
||||||
|
|
||||||
|
assert "errors" not in content
|
||||||
|
|
||||||
|
assert content["data"]["petFormMutation1"]["errors"] == [
|
||||||
|
{"field": "age", "messages": ["Too old"]}
|
||||||
|
]
|
||||||
|
|
||||||
|
assert content["data"]["petFormMutation2"]["errors"] == []
|
||||||
|
|
||||||
|
assert Pet.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@patch("graphene_django.settings.graphene_settings.ATOMIC_MUTATIONS", True)
|
||||||
|
@patch.dict(
|
||||||
|
connection.settings_dict, {"ATOMIC_MUTATIONS": False, "ATOMIC_REQUESTS": False}
|
||||||
|
)
|
||||||
|
def test_form_mutation_multiple_creation_invalid_atomic_mutation_2(client):
|
||||||
|
query = """
|
||||||
|
mutation PetMutations {
|
||||||
|
petFormMutation1: petFormMutation(input: { name: "Mia", age: 99 }) {
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
petFormMutation2: petFormMutation(input: { name: "Enzo", age: 0 }) {
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = client.post(url_string(query=query))
|
||||||
|
content = response_json(response)
|
||||||
|
|
||||||
|
assert "errors" not in content
|
||||||
|
|
||||||
|
assert content["data"]["petFormMutation1"]["errors"] == [
|
||||||
|
{"field": "age", "messages": ["Too old"]}
|
||||||
|
]
|
||||||
|
|
||||||
|
assert content["data"]["petFormMutation2"]["errors"] == []
|
||||||
|
|
||||||
|
assert Pet.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@patch("graphene_django.settings.graphene_settings.ATOMIC_MUTATIONS", False)
|
||||||
|
@patch.dict(
|
||||||
|
connection.settings_dict, {"ATOMIC_MUTATIONS": False, "ATOMIC_REQUESTS": False}
|
||||||
|
)
|
||||||
|
def test_form_mutation_multiple_creation_invalid_non_atomic(client):
|
||||||
|
query = """
|
||||||
|
mutation PetMutations {
|
||||||
|
petFormMutation1: petFormMutation(input: { name: "Mia", age: 99 }) {
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
petFormMutation2: petFormMutation(input: { name: "Enzo", age: 0 }) {
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = client.post(url_string(query=query))
|
||||||
|
content = response_json(response)
|
||||||
|
|
||||||
|
assert "errors" not in content
|
||||||
|
|
||||||
|
assert content["data"]["petFormMutation1"]["errors"] == [
|
||||||
|
{"field": "age", "messages": ["Too old"]}
|
||||||
|
]
|
||||||
|
|
||||||
|
assert content["data"]["petFormMutation2"]["errors"] == []
|
||||||
|
|
||||||
|
assert Pet.objects.count() == 1
|
||||||
|
|
||||||
|
pet = Pet.objects.get()
|
||||||
|
assert pet.name == "Enzo"
|
||||||
|
assert pet.age == 0
|
||||||
|
|
||||||
|
|
||||||
|
@patch("graphene_django.settings.graphene_settings.ATOMIC_MUTATIONS", False)
|
||||||
|
@patch.dict(
|
||||||
|
connection.settings_dict, {"ATOMIC_MUTATIONS": False, "ATOMIC_REQUESTS": True}
|
||||||
|
)
|
||||||
|
def test_model_form_mutation_multiple_creation_invalid_atomic_request(client):
|
||||||
|
query = """
|
||||||
|
mutation PetMutations {
|
||||||
|
petMutation1: petMutation(input: { name: "Mia", age: 99 }) {
|
||||||
|
pet {
|
||||||
|
name
|
||||||
|
age
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
petMutation2: petMutation(input: { name: "Enzo", age: 0 }) {
|
||||||
|
pet {
|
||||||
|
name
|
||||||
|
age
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = client.post(url_string(query=query))
|
||||||
|
content = response_json(response)
|
||||||
|
|
||||||
|
assert "errors" not in content
|
||||||
|
|
||||||
|
assert content["data"]["petMutation1"]["pet"] is None
|
||||||
|
assert content["data"]["petMutation1"]["errors"] == [
|
||||||
|
{"field": "age", "messages": ["Too old"]}
|
||||||
|
]
|
||||||
|
|
||||||
|
assert content["data"]["petMutation2"]["pet"] == {"name": "Enzo", "age": 0}
|
||||||
|
|
||||||
|
assert Pet.objects.count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
@patch("graphene_django.settings.graphene_settings.ATOMIC_MUTATIONS", False)
|
||||||
|
@patch.dict(
|
||||||
|
connection.settings_dict, {"ATOMIC_MUTATIONS": False, "ATOMIC_REQUESTS": False}
|
||||||
|
)
|
||||||
|
def test_model_form_mutation_multiple_creation_invalid_non_atomic(client):
|
||||||
|
query = """
|
||||||
|
mutation PetMutations {
|
||||||
|
petMutation1: petMutation(input: { name: "Mia", age: 99 }) {
|
||||||
|
pet {
|
||||||
|
name
|
||||||
|
age
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
petMutation2: petMutation(input: { name: "Enzo", age: 0 }) {
|
||||||
|
pet {
|
||||||
|
name
|
||||||
|
age
|
||||||
|
}
|
||||||
|
errors {
|
||||||
|
field
|
||||||
|
messages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
|
||||||
|
response = client.post(url_string(query=query))
|
||||||
|
content = response_json(response)
|
||||||
|
|
||||||
|
assert "errors" not in content
|
||||||
|
|
||||||
|
assert content["data"]["petMutation1"]["pet"] is None
|
||||||
|
assert content["data"]["petMutation1"]["errors"] == [
|
||||||
|
{"field": "age", "messages": ["Too old"]}
|
||||||
|
]
|
||||||
|
|
||||||
|
assert content["data"]["petMutation2"]["pet"] == {"name": "Enzo", "age": 0}
|
||||||
|
|
||||||
|
assert Pet.objects.count() == 1
|
||||||
|
|
||||||
|
pet = Pet.objects.get()
|
||||||
|
assert pet.name == "Enzo"
|
||||||
|
assert pet.age == 0
|
||||||
|
|
||||||
|
|
||||||
|
@patch("graphene_django.utils.utils.transaction.set_rollback")
|
||||||
|
@patch("graphene_django.settings.graphene_settings.ATOMIC_MUTATIONS", False)
|
||||||
|
@patch.dict(
|
||||||
|
connection.settings_dict, {"ATOMIC_MUTATIONS": False, "ATOMIC_REQUESTS": True}
|
||||||
|
)
|
||||||
|
def test_query_errors_atomic_request(set_rollback_mock, client):
|
||||||
|
client.get(url_string(query="force error"))
|
||||||
|
set_rollback_mock.assert_called_once_with(True)
|
||||||
|
|
||||||
|
|
||||||
|
@patch("graphene_django.utils.utils.transaction.set_rollback")
|
||||||
|
@patch("graphene_django.settings.graphene_settings.ATOMIC_MUTATIONS", False)
|
||||||
|
@patch.dict(
|
||||||
|
connection.settings_dict, {"ATOMIC_MUTATIONS": False, "ATOMIC_REQUESTS": False}
|
||||||
|
)
|
||||||
|
def test_query_errors_non_atomic(set_rollback_mock, client):
|
||||||
|
client.get(url_string(query="force error"))
|
||||||
|
set_rollback_mock.assert_not_called()
|
||||||
|
|
9
graphene_django/tests/types.py
Normal file
9
graphene_django/tests/types.py
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
from graphene_django.types import DjangoObjectType
|
||||||
|
|
||||||
|
from .models import Pet
|
||||||
|
|
||||||
|
|
||||||
|
class PetType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Pet
|
||||||
|
fields = "__all__"
|
|
@ -1,5 +1,5 @@
|
||||||
import re
|
import re
|
||||||
from unidecode import unidecode
|
from text_unidecode import unidecode
|
||||||
|
|
||||||
|
|
||||||
def to_const(string):
|
def to_const(string):
|
||||||
|
|
|
@ -45,7 +45,7 @@ def graphql_query(
|
||||||
if variables:
|
if variables:
|
||||||
body["variables"] = variables
|
body["variables"] = variables
|
||||||
if input_data:
|
if input_data:
|
||||||
if variables in body:
|
if "variables" in body:
|
||||||
body["variables"]["input"] = input_data
|
body["variables"]["input"] = input_data
|
||||||
else:
|
else:
|
||||||
body["variables"] = {"input": input_data}
|
body["variables"] = {"input": input_data}
|
||||||
|
@ -105,20 +105,20 @@ class GraphQLTestCase(TestCase):
|
||||||
graphql_url=self.GRAPHQL_URL,
|
graphql_url=self.GRAPHQL_URL,
|
||||||
)
|
)
|
||||||
|
|
||||||
def assertResponseNoErrors(self, resp):
|
def assertResponseNoErrors(self, resp, msg=None):
|
||||||
"""
|
"""
|
||||||
Assert that the call went through correctly. 200 means the syntax is ok, if there are no `errors`,
|
Assert that the call went through correctly. 200 means the syntax is ok, if there are no `errors`,
|
||||||
the call was fine.
|
the call was fine.
|
||||||
:resp HttpResponse: Response
|
:resp HttpResponse: Response
|
||||||
"""
|
"""
|
||||||
self.assertEqual(resp.status_code, 200)
|
|
||||||
content = json.loads(resp.content)
|
content = json.loads(resp.content)
|
||||||
self.assertNotIn("errors", list(content.keys()))
|
self.assertEqual(resp.status_code, 200, msg or content)
|
||||||
|
self.assertNotIn("errors", list(content.keys()), msg or content)
|
||||||
|
|
||||||
def assertResponseHasErrors(self, resp):
|
def assertResponseHasErrors(self, resp, msg=None):
|
||||||
"""
|
"""
|
||||||
Assert that the call was failing. Take care: Even with errors, GraphQL returns status 200!
|
Assert that the call was failing. Take care: Even with errors, GraphQL returns status 200!
|
||||||
:resp HttpResponse: Response
|
:resp HttpResponse: Response
|
||||||
"""
|
"""
|
||||||
content = json.loads(resp.content)
|
content = json.loads(resp.content)
|
||||||
self.assertIn("errors", list(content.keys()))
|
self.assertIn("errors", list(content.keys()), msg or content)
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import inspect
|
import inspect
|
||||||
|
|
||||||
from django.db import models
|
from django.db import connection, models, transaction
|
||||||
from django.db.models.manager import Manager
|
from django.db.models.manager import Manager
|
||||||
from django.utils.encoding import force_str
|
from django.utils.encoding import force_str
|
||||||
from django.utils.functional import Promise
|
from django.utils.functional import Promise
|
||||||
|
@ -76,3 +76,32 @@ def get_model_fields(model):
|
||||||
|
|
||||||
def is_valid_django_model(model):
|
def is_valid_django_model(model):
|
||||||
return inspect.isclass(model) and issubclass(model, models.Model)
|
return inspect.isclass(model) and issubclass(model, models.Model)
|
||||||
|
|
||||||
|
|
||||||
|
def import_single_dispatch():
|
||||||
|
try:
|
||||||
|
from functools import singledispatch
|
||||||
|
except ImportError:
|
||||||
|
singledispatch = None
|
||||||
|
|
||||||
|
if not singledispatch:
|
||||||
|
try:
|
||||||
|
from singledispatch import singledispatch
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not singledispatch:
|
||||||
|
raise Exception(
|
||||||
|
"It seems your python version does not include "
|
||||||
|
"functools.singledispatch. Please install the 'singledispatch' "
|
||||||
|
"package. More information here: "
|
||||||
|
"https://pypi.python.org/pypi/singledispatch"
|
||||||
|
)
|
||||||
|
|
||||||
|
return singledispatch
|
||||||
|
|
||||||
|
|
||||||
|
def set_rollback():
|
||||||
|
atomic_requests = connection.settings_dict.get("ATOMIC_REQUESTS", False)
|
||||||
|
if atomic_requests and connection.in_atomic_block:
|
||||||
|
transaction.set_rollback(True)
|
||||||
|
|
|
@ -2,6 +2,7 @@ import inspect
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
|
||||||
|
from django.db import connection, transaction
|
||||||
from django.http import HttpResponse, HttpResponseNotAllowed
|
from django.http import HttpResponse, HttpResponseNotAllowed
|
||||||
from django.http.response import HttpResponseBadRequest
|
from django.http.response import HttpResponseBadRequest
|
||||||
from django.shortcuts import render
|
from django.shortcuts import render
|
||||||
|
@ -16,6 +17,9 @@ from graphql.execution import ExecutionResult
|
||||||
from graphene import Schema
|
from graphene import Schema
|
||||||
from graphql.execution.middleware import MiddlewareManager
|
from graphql.execution.middleware import MiddlewareManager
|
||||||
|
|
||||||
|
from graphene_django.constants import MUTATION_ERRORS_FLAG
|
||||||
|
from graphene_django.utils.utils import set_rollback
|
||||||
|
|
||||||
from .settings import graphene_settings
|
from .settings import graphene_settings
|
||||||
|
|
||||||
|
|
||||||
|
@ -90,6 +94,7 @@ class GraphQLView(View):
|
||||||
pretty=False,
|
pretty=False,
|
||||||
batch=False,
|
batch=False,
|
||||||
subscription_path=None,
|
subscription_path=None,
|
||||||
|
execution_context_class=None,
|
||||||
):
|
):
|
||||||
if not schema:
|
if not schema:
|
||||||
schema = graphene_settings.SCHEMA
|
schema = graphene_settings.SCHEMA
|
||||||
|
@ -107,6 +112,7 @@ class GraphQLView(View):
|
||||||
self.pretty = self.pretty or pretty
|
self.pretty = self.pretty or pretty
|
||||||
self.graphiql = self.graphiql or graphiql
|
self.graphiql = self.graphiql or graphiql
|
||||||
self.batch = self.batch or batch
|
self.batch = self.batch or batch
|
||||||
|
self.execution_context_class = execution_context_class
|
||||||
if subscription_path is None:
|
if subscription_path is None:
|
||||||
self.subscription_path = graphene_settings.SUBSCRIPTION_PATH
|
self.subscription_path = graphene_settings.SUBSCRIPTION_PATH
|
||||||
|
|
||||||
|
@ -190,17 +196,21 @@ class GraphQLView(View):
|
||||||
request, data, query, variables, operation_name, show_graphiql
|
request, data, query, variables, operation_name, show_graphiql
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if getattr(request, MUTATION_ERRORS_FLAG, False) is True:
|
||||||
|
set_rollback()
|
||||||
|
|
||||||
status_code = 200
|
status_code = 200
|
||||||
if execution_result:
|
if execution_result:
|
||||||
response = {}
|
response = {}
|
||||||
|
|
||||||
if execution_result.errors:
|
if execution_result.errors:
|
||||||
|
set_rollback()
|
||||||
response["errors"] = [
|
response["errors"] = [
|
||||||
self.format_error(e) for e in execution_result.errors
|
self.format_error(e) for e in execution_result.errors
|
||||||
]
|
]
|
||||||
|
|
||||||
if execution_result.errors and any(
|
if execution_result.errors and any(
|
||||||
not e.path for e in execution_result.errors
|
not getattr(e, "path", None) for e in execution_result.errors
|
||||||
):
|
):
|
||||||
status_code = 400
|
status_code = 400
|
||||||
else:
|
else:
|
||||||
|
@ -297,14 +307,39 @@ class GraphQLView(View):
|
||||||
if validation_errors:
|
if validation_errors:
|
||||||
return ExecutionResult(data=None, errors=validation_errors)
|
return ExecutionResult(data=None, errors=validation_errors)
|
||||||
|
|
||||||
return self.schema.execute(
|
try:
|
||||||
source=query,
|
extra_options = {}
|
||||||
root_value=self.get_root_value(request),
|
if self.execution_context_class:
|
||||||
variable_values=variables,
|
extra_options["execution_context_class"] = self.execution_context_class
|
||||||
operation_name=operation_name,
|
|
||||||
context_value=self.get_context(request),
|
options = {
|
||||||
middleware=self.get_middleware(request),
|
"source": query,
|
||||||
)
|
"root_value": self.get_root_value(request),
|
||||||
|
"variable_values": variables,
|
||||||
|
"operation_name": operation_name,
|
||||||
|
"context_value": self.get_context(request),
|
||||||
|
"middleware": self.get_middleware(request),
|
||||||
|
}
|
||||||
|
options.update(extra_options)
|
||||||
|
|
||||||
|
operation_ast = get_operation_ast(document, operation_name)
|
||||||
|
if (
|
||||||
|
operation_ast
|
||||||
|
and operation_ast.operation == OperationType.MUTATION
|
||||||
|
and (
|
||||||
|
graphene_settings.ATOMIC_MUTATIONS is True
|
||||||
|
or connection.settings_dict.get("ATOMIC_MUTATIONS", False) is True
|
||||||
|
)
|
||||||
|
):
|
||||||
|
with transaction.atomic():
|
||||||
|
result = self.schema.execute(**options)
|
||||||
|
if getattr(request, MUTATION_ERRORS_FLAG, False) is True:
|
||||||
|
transaction.set_rollback(True)
|
||||||
|
return result
|
||||||
|
|
||||||
|
return self.schema.execute(**options)
|
||||||
|
except Exception as e:
|
||||||
|
return ExecutionResult(errors=[e])
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def can_display_graphiql(cls, request, data):
|
def can_display_graphiql(cls, request, data):
|
||||||
|
|
4
setup.py
4
setup.py
|
@ -56,13 +56,13 @@ setup(
|
||||||
"Framework :: Django :: 3.0",
|
"Framework :: Django :: 3.0",
|
||||||
],
|
],
|
||||||
keywords="api graphql protocol rest relay graphene",
|
keywords="api graphql protocol rest relay graphene",
|
||||||
packages=find_packages(exclude=["tests"]),
|
packages=find_packages(exclude=["tests", "examples", "examples.*"]),
|
||||||
install_requires=[
|
install_requires=[
|
||||||
"graphene>=3.0.0b5,<4",
|
"graphene>=3.0.0b5,<4",
|
||||||
"graphql-core>=3.1.0,<4",
|
"graphql-core>=3.1.0,<4",
|
||||||
"Django>=2.2",
|
"Django>=2.2",
|
||||||
"promise>=2.1",
|
"promise>=2.1",
|
||||||
"unidecode>=1.1.1,<2",
|
"text-unidecode",
|
||||||
],
|
],
|
||||||
setup_requires=["pytest-runner"],
|
setup_requires=["pytest-runner"],
|
||||||
tests_require=tests_require,
|
tests_require=tests_require,
|
||||||
|
|
3
tox.ini
3
tox.ini
|
@ -20,11 +20,12 @@ DJANGO =
|
||||||
passenv = *
|
passenv = *
|
||||||
usedevelop = True
|
usedevelop = True
|
||||||
setenv =
|
setenv =
|
||||||
DJANGO_SETTINGS_MODULE=django_test_settings
|
DJANGO_SETTINGS_MODULE=examples.django_test_settings
|
||||||
deps =
|
deps =
|
||||||
-e.[test]
|
-e.[test]
|
||||||
psycopg2-binary
|
psycopg2-binary
|
||||||
django111: Django>=1.11,<2.0
|
django111: Django>=1.11,<2.0
|
||||||
|
django111: djangorestframework<3.12
|
||||||
django20: Django>=2.0,<2.1
|
django20: Django>=2.0,<2.1
|
||||||
django21: Django>=2.1,<2.2
|
django21: Django>=2.1,<2.2
|
||||||
django22: Django>=2.2,<3.0
|
django22: Django>=2.2,<3.0
|
||||||
|
|
Loading…
Reference in New Issue
Block a user