mirror of
https://github.com/graphql-python/graphene-django.git
synced 2025-01-27 17:54:11 +03:00
Merge branch 'main' into fix-m2m-filter-type
This commit is contained in:
commit
f9eabcc7c4
9
.github/workflows/deploy.yml
vendored
9
.github/workflows/deploy.yml
vendored
|
@ -6,8 +6,13 @@ on:
|
|||
- 'v*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
lint:
|
||||
uses: ./.github/workflows/lint.yml
|
||||
tests:
|
||||
uses: ./.github/workflows/tests.yml
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, tests]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
@ -20,7 +25,7 @@ jobs:
|
|||
pip install wheel
|
||||
python setup.py sdist bdist_wheel
|
||||
- name: Publish a Python distribution to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@v1.8.6
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.pypi_password }}
|
||||
|
|
6
.github/workflows/lint.yml
vendored
6
.github/workflows/lint.yml
vendored
|
@ -1,6 +1,10 @@
|
|||
name: Lint
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
|
21
.github/workflows/tests.yml
vendored
21
.github/workflows/tests.yml
vendored
|
@ -1,6 +1,10 @@
|
|||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
@ -8,13 +12,17 @@ jobs:
|
|||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
django: ["3.2", "4.0", "4.1"]
|
||||
python-version: ["3.8", "3.9", "3.10"]
|
||||
include:
|
||||
django: ["3.2", "4.2", "5.0"]
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
exclude:
|
||||
- django: "3.2"
|
||||
python-version: "3.7"
|
||||
- django: "4.1"
|
||||
python-version: "3.11"
|
||||
- django: "3.2"
|
||||
python-version: "3.12"
|
||||
- django: "5.0"
|
||||
python-version: "3.8"
|
||||
- django: "5.0"
|
||||
python-version: "3.9"
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
|
@ -29,4 +37,3 @@ jobs:
|
|||
run: tox
|
||||
env:
|
||||
DJANGO: ${{ matrix.django }}
|
||||
TOXENV: ${{ matrix.toxenv }}
|
||||
|
|
8
.gitignore
vendored
8
.gitignore
vendored
|
@ -11,6 +11,9 @@ __pycache__/
|
|||
# Distribution / packaging
|
||||
.Python
|
||||
env/
|
||||
.env/
|
||||
venv/
|
||||
.venv/
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
|
@ -80,3 +83,8 @@ Session.vim
|
|||
tags
|
||||
.tox/
|
||||
.pytest_cache/
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
.python-version
|
||||
|
|
|
@ -2,7 +2,7 @@ default_language_version:
|
|||
python: python3.11
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.4.0
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
- id: check-merge-conflict
|
||||
- id: check-json
|
||||
|
@ -15,16 +15,9 @@ repos:
|
|||
- --autofix
|
||||
- id: trailing-whitespace
|
||||
exclude: README.md
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.3.2
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.1.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py37-plus]
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 23.3.0
|
||||
hooks:
|
||||
- id: black
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 6.0.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
- id: ruff
|
||||
args: [--fix, --exit-non-zero-on-fix, --show-fixes]
|
||||
- id: ruff-format
|
||||
|
|
18
.readthedocs.yaml
Normal file
18
.readthedocs.yaml
Normal file
|
@ -0,0 +1,18 @@
|
|||
# .readthedocs.yaml
|
||||
# Read the Docs configuration file
|
||||
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
|
||||
|
||||
version: 2
|
||||
build:
|
||||
os: ubuntu-22.04
|
||||
tools:
|
||||
python: "3.12"
|
||||
|
||||
# Build documentation in the "docs/" directory with Sphinx
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
|
||||
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
|
||||
python:
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
33
.ruff.toml
Normal file
33
.ruff.toml
Normal file
|
@ -0,0 +1,33 @@
|
|||
select = [
|
||||
"E", # pycodestyle
|
||||
"W", # pycodestyle
|
||||
"F", # pyflake
|
||||
"I", # isort
|
||||
"B", # flake8-bugbear
|
||||
"C4", # flake8-comprehensions
|
||||
"UP", # pyupgrade
|
||||
]
|
||||
|
||||
ignore = [
|
||||
"E501", # line-too-long
|
||||
"B017", # pytest.raises(Exception) should be considered evil
|
||||
"B028", # warnings.warn called without an explicit stacklevel keyword argument
|
||||
"B904", # check for raise statements in exception handlers that lack a from clause
|
||||
"W191", # https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules
|
||||
]
|
||||
|
||||
exclude = [
|
||||
"**/docs",
|
||||
]
|
||||
|
||||
target-version = "py38"
|
||||
|
||||
[per-file-ignores]
|
||||
# Ignore unused imports (F401) in these files
|
||||
"__init__.py" = ["F401"]
|
||||
"graphene_django/compat.py" = ["F401"]
|
||||
|
||||
[isort]
|
||||
known-first-party = ["graphene", "graphene-django"]
|
||||
known-local-folder = ["cookbook"]
|
||||
combine-as-imports = true
|
|
@ -33,7 +33,7 @@ make tests
|
|||
|
||||
## Opening Pull Requests
|
||||
|
||||
Please fork the project and open a pull request against the master branch.
|
||||
Please fork the project and open a pull request against the `main` branch.
|
||||
|
||||
This will trigger a series of test and lint checks.
|
||||
|
||||
|
|
6
Makefile
6
Makefile
|
@ -10,15 +10,15 @@ dev-setup:
|
|||
|
||||
.PHONY: tests ## Run unit tests
|
||||
tests:
|
||||
py.test graphene_django --cov=graphene_django -vv
|
||||
PYTHONPATH=. pytest graphene_django --cov=graphene_django -vv
|
||||
|
||||
.PHONY: format ## Format code
|
||||
format:
|
||||
black graphene_django examples setup.py
|
||||
ruff format graphene_django examples setup.py
|
||||
|
||||
.PHONY: lint ## Lint code
|
||||
lint:
|
||||
flake8 graphene_django examples
|
||||
ruff graphene_django examples
|
||||
|
||||
.PHONY: docs ## Generate docs
|
||||
docs: dev-setup
|
||||
|
|
155
README.md
155
README.md
|
@ -1,8 +1,5 @@
|
|||
# ![Graphene Logo](http://graphene-python.org/favicon.png) Graphene-Django
|
||||
|
||||
|
||||
A [Django](https://www.djangoproject.com/) integration for [Graphene](http://graphene-python.org/).
|
||||
|
||||
[![build][build-image]][build-url]
|
||||
[![pypi][pypi-image]][pypi-url]
|
||||
[![Anaconda-Server Badge][conda-image]][conda-url]
|
||||
|
@ -17,107 +14,137 @@ A [Django](https://www.djangoproject.com/) integration for [Graphene](http://gra
|
|||
[conda-image]: https://img.shields.io/conda/vn/conda-forge/graphene-django.svg
|
||||
[conda-url]: https://anaconda.org/conda-forge/graphene-django
|
||||
|
||||
[💬 Join the community on Slack](https://join.slack.com/t/graphenetools/shared_invite/enQtOTE2MDQ1NTg4MDM1LTA4Nzk0MGU0NGEwNzUxZGNjNDQ4ZjAwNDJjMjY0OGE1ZDgxZTg4YjM2ZTc4MjE2ZTAzZjE2ZThhZTQzZTkyMmM)
|
||||
Graphene-Django is an open-source library that provides seamless integration between Django, a high-level Python web framework, and Graphene, a library for building GraphQL APIs. The library allows developers to create GraphQL APIs in Django quickly and efficiently while maintaining a high level of performance.
|
||||
|
||||
## Documentation
|
||||
## Features
|
||||
|
||||
[Visit the documentation to get started!](https://docs.graphene-python.org/projects/django/en/latest/)
|
||||
* Seamless integration with Django models
|
||||
* Automatic generation of GraphQL schema
|
||||
* Integration with Django's authentication and permission system
|
||||
* Easy querying and filtering of data
|
||||
* Support for Django's pagination system
|
||||
* Compatible with Django's form and validation system
|
||||
* Extensive documentation and community support
|
||||
|
||||
## Quickstart
|
||||
## Installation
|
||||
|
||||
For installing graphene, just run this command in your shell
|
||||
To install Graphene-Django, run the following command:
|
||||
|
||||
```bash
|
||||
pip install "graphene-django>=3"
|
||||
```sh
|
||||
pip install graphene-django
|
||||
```
|
||||
|
||||
### Settings
|
||||
## Configuration
|
||||
|
||||
After installation, add 'graphene_django' to your Django project's `INSTALLED_APPS` list and define the GraphQL schema in your project's settings:
|
||||
|
||||
```python
|
||||
INSTALLED_APPS = (
|
||||
INSTALLED_APPS = [
|
||||
# ...
|
||||
'django.contrib.staticfiles', # Required for GraphiQL
|
||||
'graphene_django',
|
||||
)
|
||||
]
|
||||
|
||||
GRAPHENE = {
|
||||
'SCHEMA': 'app.schema.schema' # Where your Graphene schema lives
|
||||
'SCHEMA': 'myapp.schema.schema'
|
||||
}
|
||||
```
|
||||
|
||||
### Urls
|
||||
## Usage
|
||||
|
||||
We need to set up a `GraphQL` endpoint in our Django app, so we can serve the queries.
|
||||
To use Graphene-Django, create a `schema.py` file in your Django app directory and define your GraphQL types and queries:
|
||||
|
||||
```python
|
||||
from django.urls import path
|
||||
from graphene_django.views import GraphQLView
|
||||
|
||||
urlpatterns = [
|
||||
# ...
|
||||
path('graphql/', GraphQLView.as_view(graphiql=True)),
|
||||
]
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
Here is a simple Django model:
|
||||
|
||||
```python
|
||||
from django.db import models
|
||||
|
||||
class UserModel(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
last_name = models.CharField(max_length=100)
|
||||
```
|
||||
|
||||
To create a GraphQL schema for it you simply have to write the following:
|
||||
|
||||
```python
|
||||
from graphene_django import DjangoObjectType
|
||||
import graphene
|
||||
from graphene_django import DjangoObjectType
|
||||
from .models import MyModel
|
||||
|
||||
class User(DjangoObjectType):
|
||||
class MyModelType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = UserModel
|
||||
model = MyModel
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
users = graphene.List(User)
|
||||
mymodels = graphene.List(MyModelType)
|
||||
|
||||
def resolve_users(self, info):
|
||||
return UserModel.objects.all()
|
||||
def resolve_mymodels(self, info, **kwargs):
|
||||
return MyModel.objects.all()
|
||||
|
||||
schema = graphene.Schema(query=Query)
|
||||
```
|
||||
|
||||
Then you can query the schema:
|
||||
Then, expose the GraphQL API in your Django project's `urls.py` file:
|
||||
|
||||
```python
|
||||
query = '''
|
||||
query {
|
||||
users {
|
||||
name,
|
||||
lastName
|
||||
}
|
||||
}
|
||||
'''
|
||||
result = schema.execute(query)
|
||||
from django.urls import path
|
||||
from graphene_django.views import GraphQLView
|
||||
from . import schema
|
||||
|
||||
urlpatterns = [
|
||||
# ...
|
||||
path('graphql/', GraphQLView.as_view(graphiql=True)), # Given that schema path is defined in GRAPHENE['SCHEMA'] in your settings.py
|
||||
]
|
||||
```
|
||||
|
||||
To learn more check out the following [examples](examples/):
|
||||
## Testing
|
||||
|
||||
* **Schema with Filtering**: [Cookbook example](examples/cookbook)
|
||||
* **Relay Schema**: [Starwars Relay example](examples/starwars)
|
||||
Graphene-Django provides support for testing GraphQL APIs using Django's test client. To create tests, create a `tests.py` file in your Django app directory and write your test cases:
|
||||
|
||||
```python
|
||||
from django.test import TestCase
|
||||
from graphene_django.utils.testing import GraphQLTestCase
|
||||
from . import schema
|
||||
|
||||
## GraphQL testing clients
|
||||
- [Firecamp](https://firecamp.io/graphql)
|
||||
- [GraphiQL](https://github.com/graphql/graphiql)
|
||||
class MyModelAPITestCase(GraphQLTestCase):
|
||||
GRAPHENE_SCHEMA = schema.schema
|
||||
|
||||
def test_query_all_mymodels(self):
|
||||
response = self.query(
|
||||
'''
|
||||
query {
|
||||
mymodels {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
'''
|
||||
)
|
||||
|
||||
self.assertResponseNoErrors(response)
|
||||
self.assertEqual(len(response.data['mymodels']), MyModel.objects.count())
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
Contributions to Graphene-Django are always welcome! To get started, check the repository's [issue tracker](https://github.com/graphql-python/graphene-django/issues) and [contribution guidelines](https://github.com/graphql-python/graphene-django/blob/main/CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
Graphene-Django is released under the [MIT License](https://github.com/graphql-python/graphene-django/blob/main/LICENSE).
|
||||
|
||||
## Resources
|
||||
|
||||
* [Official GitHub Repository](https://github.com/graphql-python/graphene-django)
|
||||
* [Graphene Documentation](http://docs.graphene-python.org/en/latest/)
|
||||
* [Django Documentation](https://docs.djangoproject.com/en/stable/)
|
||||
* [GraphQL Specification](https://spec.graphql.org/)
|
||||
* [GraphiQL](https://github.com/graphql/graphiql) - An in-browser IDE for exploring GraphQL APIs
|
||||
* [Graphene-Django Community](https://spectrum.chat/graphene) - Join the community to discuss questions and share ideas related to Graphene-Django
|
||||
|
||||
## Tutorials and Examples
|
||||
|
||||
* [Official Graphene-Django Tutorial](https://docs.graphene-python.org/projects/django/en/latest/tutorial-plain/)
|
||||
* [Building a GraphQL API with Django and Graphene-Django](https://www.howtographql.com/graphql-python/0-introduction/)
|
||||
* [Real-world example: Django, Graphene, and Relay](https://github.com/graphql-python/swapi-graphene)
|
||||
|
||||
## Related Projects
|
||||
|
||||
* [Graphene](https://github.com/graphql-python/graphene) - A library for building GraphQL APIs in Python
|
||||
* [Graphene-SQLAlchemy](https://github.com/graphql-python/graphene-sqlalchemy) - Integration between Graphene and SQLAlchemy, an Object Relational Mapper (ORM) for Python
|
||||
* [Graphene-File-Upload](https://github.com/lmcgartland/graphene-file-upload) - A package providing an Upload scalar for handling file uploads in Graphene
|
||||
* [Graphene-Subscriptions](https://github.com/graphql-python/graphene-subscriptions) - A package for adding real-time subscriptions to Graphene-based GraphQL APIs
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter any issues or have questions regarding Graphene-Django, feel free to [submit an issue](https://github.com/graphql-python/graphene-django/issues/new) on the official GitHub repository. You can also ask for help and share your experiences with the Graphene-Django community on [💬 Discord](https://discord.gg/Fftt273T79)
|
||||
|
||||
## Release Notes
|
||||
|
||||
|
|
122
README.rst
122
README.rst
|
@ -1,122 +0,0 @@
|
|||
Please read
|
||||
`UPGRADE-v2.0.md <https://github.com/graphql-python/graphene/blob/master/UPGRADE-v2.0.md>`__
|
||||
to learn how to upgrade to Graphene ``2.0``.
|
||||
|
||||
--------------
|
||||
|
||||
|Graphene Logo| Graphene-Django |Build Status| |PyPI version| |Coverage Status|
|
||||
===============================================================================
|
||||
|
||||
A `Django <https://www.djangoproject.com/>`__ integration for
|
||||
`Graphene <http://graphene-python.org/>`__.
|
||||
|
||||
|
||||
Documentation
|
||||
-------------
|
||||
|
||||
`Visit the documentation to get started! <https://docs.graphene-python.org/projects/django/en/latest/>`__
|
||||
|
||||
Quickstart
|
||||
----------
|
||||
|
||||
For installing graphene, just run this command in your shell
|
||||
|
||||
.. code:: bash
|
||||
|
||||
pip install "graphene-django>=3"
|
||||
|
||||
Settings
|
||||
~~~~~~~~
|
||||
|
||||
.. code:: python
|
||||
|
||||
INSTALLED_APPS = (
|
||||
# ...
|
||||
'graphene_django',
|
||||
)
|
||||
|
||||
GRAPHENE = {
|
||||
'SCHEMA': 'app.schema.schema' # Where your Graphene schema lives
|
||||
}
|
||||
|
||||
Urls
|
||||
~~~~
|
||||
|
||||
We need to set up a ``GraphQL`` endpoint in our Django app, so we can
|
||||
serve the queries.
|
||||
|
||||
.. code:: python
|
||||
|
||||
from django.conf.urls import url
|
||||
from graphene_django.views import GraphQLView
|
||||
|
||||
urlpatterns = [
|
||||
# ...
|
||||
url(r'^graphql$', GraphQLView.as_view(graphiql=True)),
|
||||
]
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
Here is a simple Django model:
|
||||
|
||||
.. code:: python
|
||||
|
||||
from django.db import models
|
||||
|
||||
class UserModel(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
last_name = models.CharField(max_length=100)
|
||||
|
||||
To create a GraphQL schema for it you simply have to write the
|
||||
following:
|
||||
|
||||
.. code:: python
|
||||
|
||||
from graphene_django import DjangoObjectType
|
||||
import graphene
|
||||
|
||||
class User(DjangoObjectType):
|
||||
class Meta:
|
||||
model = UserModel
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
users = graphene.List(User)
|
||||
|
||||
@graphene.resolve_only_args
|
||||
def resolve_users(self):
|
||||
return UserModel.objects.all()
|
||||
|
||||
schema = graphene.Schema(query=Query)
|
||||
|
||||
Then you can simply query the schema:
|
||||
|
||||
.. code:: python
|
||||
|
||||
query = '''
|
||||
query {
|
||||
users {
|
||||
name,
|
||||
lastName
|
||||
}
|
||||
}
|
||||
'''
|
||||
result = schema.execute(query)
|
||||
|
||||
To learn more check out the following `examples <examples/>`__:
|
||||
|
||||
- **Schema with Filtering**: `Cookbook example <examples/cookbook>`__
|
||||
- **Relay Schema**: `Starwars Relay example <examples/starwars>`__
|
||||
|
||||
Contributing
|
||||
------------
|
||||
|
||||
See `CONTRIBUTING.md <CONTRIBUTING.md>`__.
|
||||
|
||||
.. |Graphene Logo| image:: http://graphene-python.org/favicon.png
|
||||
.. |Build Status| image:: https://github.com/graphql-python/graphene-django/workflows/Tests/badge.svg
|
||||
:target: https://github.com/graphql-python/graphene-django/actions
|
||||
.. |PyPI version| image:: https://badge.fury.io/py/graphene-django.svg
|
||||
: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
|
||||
:target: https://coveralls.io/github/graphql-python/graphene-django?branch=master
|
|
@ -144,6 +144,21 @@ If you are using ``DjangoObjectType`` you can define a custom `get_queryset`.
|
|||
return queryset.filter(published=True)
|
||||
return queryset
|
||||
|
||||
.. warning::
|
||||
|
||||
Defining a custom ``get_queryset`` gives the guaranteed it will be called
|
||||
when resolving the ``DjangoObjectType``, even through related objects.
|
||||
Note that because of this, benefits from using ``select_related``
|
||||
in objects that define a relation to this ``DjangoObjectType`` will be canceled out.
|
||||
In the case of ``prefetch_related``, the benefits of the optimization will be lost only
|
||||
if the custom ``get_queryset`` modifies the queryset. For more information about this, refers
|
||||
to Django documentation about ``prefetch_related``: https://docs.djangoproject.com/en/4.2/ref/models/querysets/#prefetch-related.
|
||||
|
||||
|
||||
If you want to explicitly disable the execution of the custom ``get_queryset`` when resolving,
|
||||
you can decorate the resolver with `@graphene_django.bypass_get_queryset`. Note that this
|
||||
can lead to authorization leaks if you are performing authorization checks in the custom
|
||||
``get_queryset``.
|
||||
|
||||
Filtering ID-based Node Access
|
||||
------------------------------
|
||||
|
@ -197,8 +212,8 @@ For Django 2.2 and above:
|
|||
.. code:: python
|
||||
|
||||
urlpatterns = [
|
||||
# some other urls
|
||||
path('graphql/', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
|
||||
# some other urls
|
||||
path('graphql/', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
|
||||
]
|
||||
|
||||
.. _LoginRequiredMixin: https://docs.djangoproject.com/en/dev/topics/auth/default/#the-loginrequired-mixin
|
||||
|
|
|
@ -78,7 +78,7 @@ release = "1.0.dev"
|
|||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = None
|
||||
# language = None
|
||||
|
||||
# There are two options for replacing |today|: either, you set today to some
|
||||
# non-false value, then it is used:
|
||||
|
@ -445,4 +445,7 @@ epub_exclude_files = ["search.html"]
|
|||
|
||||
|
||||
# Example configuration for intersphinx: refer to the Python standard library.
|
||||
intersphinx_mapping = {"https://docs.python.org/": None}
|
||||
intersphinx_mapping = {
|
||||
# "https://docs.python.org/": None,
|
||||
"python": ("https://docs.python.org/", None),
|
||||
}
|
||||
|
|
|
@ -33,5 +33,6 @@ For more advanced use, check out the Relay tutorial.
|
|||
authorization
|
||||
debug
|
||||
introspection
|
||||
validation
|
||||
testing
|
||||
settings
|
||||
|
|
|
@ -57,9 +57,9 @@ specify the parameters in your settings.py:
|
|||
.. code:: python
|
||||
|
||||
GRAPHENE = {
|
||||
'SCHEMA': 'tutorial.quickstart.schema',
|
||||
'SCHEMA_OUTPUT': 'data/schema.json', # defaults to schema.json,
|
||||
'SCHEMA_INDENT': 2, # Defaults to None (displays all data on a single line)
|
||||
'SCHEMA': 'tutorial.quickstart.schema',
|
||||
'SCHEMA_OUTPUT': 'data/schema.json', # defaults to schema.json,
|
||||
'SCHEMA_INDENT': 2, # Defaults to None (displays all data on a single line)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -125,6 +125,55 @@ to change how the form is saved or to return a different Graphene object type.
|
|||
If the form is *not* valid then a list of errors will be returned. These errors have two fields: ``field``, a string
|
||||
containing the name of the invalid form field, and ``messages``, a list of strings with the validation messages.
|
||||
|
||||
DjangoFormInputObjectType
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
``DjangoFormInputObjectType`` is used in mutations to create input fields by **using django form** to retrieve input data structure from it. This can be helpful in situations where you need to pass data to several django forms in one mutation.
|
||||
|
||||
.. code:: python
|
||||
|
||||
from graphene_django.forms.types import DjangoFormInputObjectType
|
||||
|
||||
|
||||
class PetFormInput(DjangoFormInputObjectType):
|
||||
# any other fields can be placed here as well as
|
||||
# other djangoforminputobjects and intputobjects
|
||||
class Meta:
|
||||
form_class = PetForm
|
||||
object_type = PetType
|
||||
|
||||
class QuestionFormInput(DjangoFormInputObjectType)
|
||||
class Meta:
|
||||
form_class = QuestionForm
|
||||
object_type = QuestionType
|
||||
|
||||
class SeveralFormsInputData(graphene.InputObjectType):
|
||||
pet = PetFormInput(required=True)
|
||||
question = QuestionFormInput(required=True)
|
||||
|
||||
class SomeSophisticatedMutation(graphene.Mutation):
|
||||
class Arguments:
|
||||
data = SeveralFormsInputData(required=True)
|
||||
|
||||
@staticmethod
|
||||
def mutate(_root, _info, data):
|
||||
pet_form_inst = PetForm(data=data.pet)
|
||||
question_form_inst = QuestionForm(data=data.question)
|
||||
|
||||
if pet_form_inst.is_valid():
|
||||
pet_model_instance = pet_form_inst.save(commit=False)
|
||||
|
||||
if question_form_inst.is_valid():
|
||||
question_model_instance = question_form_inst.save(commit=False)
|
||||
|
||||
# ...
|
||||
|
||||
Additional to **InputObjectType** ``Meta`` class attributes:
|
||||
|
||||
* ``form_class`` is required and should be equal to django form class.
|
||||
* ``object_type`` is not required and used to enable convertion of enum values back to original if model object type ``convert_choices_to_enum`` ``Meta`` class attribute is not set to ``False``. Any data field, which have choices in django, with value ``A_1`` (for example) from client will be automatically converted to ``1`` in mutation data.
|
||||
* ``add_id_field_name`` is used to specify `id` field name (not required, by default equal to ``id``)
|
||||
* ``add_id_field_type`` is used to specify `id` field type (not required, default is ``graphene.ID``)
|
||||
|
||||
Django REST Framework
|
||||
---------------------
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
Sphinx==1.5.3
|
||||
sphinx-autobuild==0.7.1
|
||||
Sphinx==7.0.0
|
||||
sphinx-autobuild==2021.3.14
|
||||
pygments-graphql-lexer==0.1.0
|
||||
# Docs template
|
||||
http://graphene-python.org/sphinx_graphene_theme.zip
|
||||
|
|
|
@ -6,7 +6,7 @@ Graphene-Django can be customised using settings. This page explains each settin
|
|||
Usage
|
||||
-----
|
||||
|
||||
Add settings to your Django project by creating a Dictonary with name ``GRAPHENE`` in the project's ``settings.py``:
|
||||
Add settings to your Django project by creating a Dictionary with name ``GRAPHENE`` in the project's ``settings.py``:
|
||||
|
||||
.. code:: python
|
||||
|
||||
|
@ -142,6 +142,15 @@ Default: ``False``
|
|||
# ]
|
||||
|
||||
|
||||
``DJANGO_CHOICE_FIELD_ENUM_CONVERT``
|
||||
--------------------------------------
|
||||
|
||||
When set to ``True`` Django choice fields are automatically converted into Enum types.
|
||||
|
||||
Can be disabled globally by setting it to ``False``.
|
||||
|
||||
Default: ``True``
|
||||
|
||||
``DJANGO_CHOICE_FIELD_ENUM_V2_NAMING``
|
||||
--------------------------------------
|
||||
|
||||
|
@ -197,9 +206,6 @@ Set to ``False`` if you want to disable GraphiQL headers editor tab for some rea
|
|||
|
||||
This setting is passed to ``headerEditorEnabled`` GraphiQL options, for details refer to GraphiQLDocs_.
|
||||
|
||||
.. _GraphiQLDocs: https://github.com/graphql/graphiql/tree/main/packages/graphiql#options
|
||||
|
||||
|
||||
Default: ``True``
|
||||
|
||||
.. code:: python
|
||||
|
@ -224,14 +230,12 @@ Default: ``/graphql``
|
|||
|
||||
|
||||
``GRAPHIQL_SHOULD_PERSIST_HEADERS``
|
||||
---------------------
|
||||
-----------------------------------
|
||||
|
||||
Set to ``True`` if you want to persist GraphiQL headers after refreshing the page.
|
||||
|
||||
This setting is passed to ``shouldPersistHeaders`` GraphiQL options, for details refer to GraphiQLDocs_.
|
||||
|
||||
.. _GraphiQLDocs: https://github.com/graphql/graphiql/tree/main/packages/graphiql#options
|
||||
|
||||
|
||||
Default: ``False``
|
||||
|
||||
|
@ -240,3 +244,48 @@ Default: ``False``
|
|||
GRAPHENE = {
|
||||
'GRAPHIQL_SHOULD_PERSIST_HEADERS': False,
|
||||
}
|
||||
|
||||
|
||||
``GRAPHIQL_INPUT_VALUE_DEPRECATION``
|
||||
------------------------------------
|
||||
|
||||
Set to ``True`` if you want GraphiQL to show any deprecated fields on input object types' docs.
|
||||
|
||||
For example, having this schema:
|
||||
|
||||
.. code:: python
|
||||
|
||||
class MyMutationInputType(graphene.InputObjectType):
|
||||
old_field = graphene.String(deprecation_reason="You should now use 'newField' instead.")
|
||||
new_field = graphene.String()
|
||||
|
||||
class MyMutation(graphene.Mutation):
|
||||
class Arguments:
|
||||
input = types.MyMutationInputType()
|
||||
|
||||
GraphiQL will add a ``Show Deprecated Fields`` button to toggle information display on ``oldField`` and its deprecation
|
||||
reason. Otherwise, you would get neither a button nor any information at all on ``oldField``.
|
||||
|
||||
This setting is passed to ``inputValueDeprecation`` GraphiQL options, for details refer to GraphiQLDocs_.
|
||||
|
||||
Default: ``False``
|
||||
|
||||
.. code:: python
|
||||
|
||||
GRAPHENE = {
|
||||
'GRAPHIQL_INPUT_VALUE_DEPRECATION': False,
|
||||
}
|
||||
|
||||
|
||||
.. _GraphiQLDocs: https://graphiql-test.netlify.app/typedoc/modules/graphiql_react#graphiqlprovider-2
|
||||
|
||||
|
||||
``MAX_VALIDATION_ERRORS``
|
||||
------------------------------------
|
||||
|
||||
In case ``validation_rules`` are provided to ``GraphQLView``, if this is set to a non-negative ``int`` value,
|
||||
``graphql.validation.validate`` will stop validation after this number of errors has been reached.
|
||||
If not set or set to ``None``, the maximum number of errors will follow ``graphql.validation.validate`` default
|
||||
*i.e.* 100.
|
||||
|
||||
Default: ``None``
|
||||
|
|
|
@ -104,7 +104,7 @@ Load some test data
|
|||
|
||||
Now is a good time to load up some test data. The easiest option will be
|
||||
to `download the
|
||||
ingredients.json <https://raw.githubusercontent.com/graphql-python/graphene-django/master/examples/cookbook/cookbook/ingredients/fixtures/ingredients.json>`__
|
||||
ingredients.json <https://raw.githubusercontent.com/graphql-python/graphene-django/main/examples/cookbook/cookbook/ingredients/fixtures/ingredients.json>`__
|
||||
fixture and place it in
|
||||
``cookbook/ingredients/fixtures/ingredients.json``. You can then run the
|
||||
following:
|
||||
|
|
|
@ -7,12 +7,12 @@ Graphene has a number of additional features that are designed to make
|
|||
working with Django *really simple*.
|
||||
|
||||
Note: The code in this quickstart is pulled from the `cookbook example
|
||||
app <https://github.com/graphql-python/graphene-django/tree/master/examples/cookbook>`__.
|
||||
app <https://github.com/graphql-python/graphene-django/tree/main/examples/cookbook>`__.
|
||||
|
||||
A good idea is to check the following things first:
|
||||
|
||||
* `Graphene Relay documentation <http://docs.graphene-python.org/en/latest/relay/>`__
|
||||
* `GraphQL Relay Specification <https://facebook.github.io/relay/docs/en/graphql-server-specification.html>`__
|
||||
* `GraphQL Relay Specification <https://relay.dev/docs/guides/graphql-server-specification/>`__
|
||||
|
||||
Setup the Django project
|
||||
------------------------
|
||||
|
@ -87,7 +87,7 @@ Load some test data
|
|||
|
||||
Now is a good time to load up some test data. The easiest option will be
|
||||
to `download the
|
||||
ingredients.json <https://raw.githubusercontent.com/graphql-python/graphene-django/master/examples/cookbook/cookbook/ingredients/fixtures/ingredients.json>`__
|
||||
ingredients.json <https://raw.githubusercontent.com/graphql-python/graphene-django/main/examples/cookbook/cookbook/ingredients/fixtures/ingredients.json>`__
|
||||
fixture and place it in
|
||||
``cookbook/ingredients/fixtures/ingredients.json``. You can then run the
|
||||
following:
|
||||
|
|
29
docs/validation.rst
Normal file
29
docs/validation.rst
Normal file
|
@ -0,0 +1,29 @@
|
|||
Query Validation
|
||||
================
|
||||
|
||||
Graphene-Django supports query validation by allowing passing a list of validation rules (subclasses of `ValidationRule <https://github.com/graphql-python/graphql-core/blob/v3.2.3/src/graphql/validation/rules/__init__.py>`_ from graphql-core) to the ``validation_rules`` option in ``GraphQLView``.
|
||||
|
||||
.. code:: python
|
||||
|
||||
from django.urls import path
|
||||
from graphene.validation import DisableIntrospection
|
||||
from graphene_django.views import GraphQLView
|
||||
|
||||
urlpatterns = [
|
||||
path("graphql", GraphQLView.as_view(validation_rules=(DisableIntrospection,))),
|
||||
]
|
||||
|
||||
or
|
||||
|
||||
.. code:: python
|
||||
|
||||
from django.urls import path
|
||||
from graphene.validation import DisableIntrospection
|
||||
from graphene_django.views import GraphQLView
|
||||
|
||||
class View(GraphQLView):
|
||||
validation_rules = (DisableIntrospection,)
|
||||
|
||||
urlpatterns = [
|
||||
path("graphql", View.as_view()),
|
||||
]
|
|
@ -62,3 +62,12 @@ Now head on over to
|
|||
and run some queries!
|
||||
(See the [Graphene-Django Tutorial](http://docs.graphene-python.org/projects/django/en/latest/tutorial-plain/#testing-our-graphql-schema)
|
||||
for some example queries)
|
||||
|
||||
Testing local graphene-django changes
|
||||
-------------------------------------
|
||||
|
||||
In `requirements.txt`, replace the entire `graphene-django=...` line with the following (so that we install the local version instead of the one from PyPI):
|
||||
|
||||
```
|
||||
../../ # graphene-django
|
||||
```
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import graphene
|
||||
from graphene_django.debug import DjangoDebug
|
||||
|
||||
import cookbook.ingredients.schema
|
||||
import cookbook.recipes.schema
|
||||
import graphene
|
||||
|
||||
from graphene_django.debug import DjangoDebug
|
||||
|
||||
|
||||
class Query(
|
||||
|
|
|
@ -5,10 +5,10 @@ Django settings for cookbook project.
|
|||
Generated by 'django-admin startproject' using Django 1.9.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/1.9/topics/settings/
|
||||
https://docs.djangoproject.com/en/3.2/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/1.9/ref/settings/
|
||||
https://docs.djangoproject.com/en/3.2/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
@ -18,7 +18,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/
|
||||
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = "_$=$%eqxk$8ss4n7mtgarw^5$8^d5+c83!vwatr@i_81myb=e4"
|
||||
|
@ -81,7 +81,7 @@ WSGI_APPLICATION = "cookbook.wsgi.application"
|
|||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/1.9/ref/settings/#databases
|
||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
|
@ -90,9 +90,11 @@ DATABASES = {
|
|||
}
|
||||
}
|
||||
|
||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
|
||||
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators
|
||||
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
|
@ -105,7 +107,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/1.9/topics/i18n/
|
||||
# https://docs.djangoproject.com/en/3.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = "en-us"
|
||||
|
||||
|
@ -119,6 +121,6 @@ USE_TZ = True
|
|||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/1.9/howto/static-files/
|
||||
# https://docs.djangoproject.com/en/3.2/howto/static-files/
|
||||
|
||||
STATIC_URL = "/static/"
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
from django.urls import path
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
|
||||
from graphene_django.views import GraphQLView
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
path("graphql/", GraphQLView.as_view(graphiql=True)),
|
||||
|
|
|
@ -1,4 +1,3 @@
|
|||
graphene>=2.1,<3
|
||||
graphene-django>=2.1,<3
|
||||
graphql-core>=2.1,<3
|
||||
django==3.1.14
|
||||
django~=3.2
|
||||
graphene
|
||||
graphene-django>=3.1
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
from cookbook.ingredients.models import Category, Ingredient
|
||||
from graphene import Node
|
||||
from graphene_django.filter import DjangoFilterConnectionField
|
||||
from graphene_django.types import DjangoObjectType
|
||||
|
||||
from cookbook.ingredients.models import Category, Ingredient
|
||||
|
||||
|
||||
# Graphene will automatically map the Category model's fields onto the CategoryNode.
|
||||
# This is configured in the CategoryNode's Meta class (as you can see below)
|
||||
|
|
|
@ -6,7 +6,9 @@ from cookbook.ingredients.models import Ingredient
|
|||
class Recipe(models.Model):
|
||||
title = models.CharField(max_length=100)
|
||||
instructions = models.TextField()
|
||||
__unicode__ = lambda self: self.title
|
||||
|
||||
def __unicode__(self):
|
||||
return self.title
|
||||
|
||||
|
||||
class RecipeIngredient(models.Model):
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
from cookbook.recipes.models import Recipe, RecipeIngredient
|
||||
from graphene import Node
|
||||
from graphene_django.filter import DjangoFilterConnectionField
|
||||
from graphene_django.types import DjangoObjectType
|
||||
|
||||
from cookbook.recipes.models import Recipe, RecipeIngredient
|
||||
|
||||
|
||||
class RecipeNode(DjangoObjectType):
|
||||
class Meta:
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import graphene
|
||||
from graphene_django.debug import DjangoDebug
|
||||
|
||||
import cookbook.ingredients.schema
|
||||
import cookbook.recipes.schema
|
||||
import graphene
|
||||
|
||||
from graphene_django.debug import DjangoDebug
|
||||
|
||||
|
||||
class Query(
|
||||
|
|
|
@ -3,7 +3,6 @@ from django.contrib import admin
|
|||
|
||||
from graphene_django.views import GraphQLView
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
url(r"^admin/", admin.site.urls),
|
||||
url(r"^graphql$", GraphQLView.as_view(graphiql=True)),
|
||||
|
|
|
@ -231,7 +231,7 @@
|
|||
"fields": {
|
||||
"category": 3,
|
||||
"name": "Newt",
|
||||
"notes": "Braised and Confuesd"
|
||||
"notes": "Braised and Confused"
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 5
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
graphene>=2.1,<3
|
||||
graphene-django>=2.1,<3
|
||||
graphql-core>=2.1,<3
|
||||
django==3.1.14
|
||||
django==3.2.25
|
||||
django-filter>=2
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import sys
|
||||
import os
|
||||
import sys
|
||||
|
||||
ROOT_PATH = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, ROOT_PATH + "/examples/")
|
||||
|
|
|
@ -28,7 +28,7 @@ def initialize():
|
|||
|
||||
# Yeah, technically it's Corellian. But it flew in the service of the rebels,
|
||||
# so for the purposes of this demo it's a rebel ship.
|
||||
falcon = Ship(id="4", name="Millenium Falcon", faction=rebels)
|
||||
falcon = Ship(id="4", name="Millennium Falcon", faction=rebels)
|
||||
falcon.save()
|
||||
|
||||
homeOne = Ship(id="5", name="Home One", faction=rebels)
|
||||
|
|
|
@ -3,9 +3,11 @@ from graphene import Schema, relay, resolve_only_args
|
|||
from graphene_django import DjangoConnectionField, DjangoObjectType
|
||||
|
||||
from .data import create_ship, get_empire, get_faction, get_rebels, get_ship, get_ships
|
||||
from .models import Character as CharacterModel
|
||||
from .models import Faction as FactionModel
|
||||
from .models import Ship as ShipModel
|
||||
from .models import (
|
||||
Character as CharacterModel,
|
||||
Faction as FactionModel,
|
||||
Ship as ShipModel,
|
||||
)
|
||||
|
||||
|
||||
class Ship(DjangoObjectType):
|
||||
|
|
|
@ -40,7 +40,7 @@ def test_mutations():
|
|||
{"node": {"id": "U2hpcDox", "name": "X-Wing"}},
|
||||
{"node": {"id": "U2hpcDoy", "name": "Y-Wing"}},
|
||||
{"node": {"id": "U2hpcDoz", "name": "A-Wing"}},
|
||||
{"node": {"id": "U2hpcDo0", "name": "Millenium Falcon"}},
|
||||
{"node": {"id": "U2hpcDo0", "name": "Millennium Falcon"}},
|
||||
{"node": {"id": "U2hpcDo1", "name": "Home One"}},
|
||||
{"node": {"id": "U2hpcDo5", "name": "Peter"}},
|
||||
]
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
from .fields import DjangoConnectionField, DjangoListField
|
||||
from .types import DjangoObjectType
|
||||
from .utils import bypass_get_queryset
|
||||
|
||||
__version__ = "3.0.2"
|
||||
__version__ = "3.2.2"
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"DjangoObjectType",
|
||||
"DjangoListField",
|
||||
"DjangoConnectionField",
|
||||
"bypass_get_queryset",
|
||||
]
|
||||
|
|
|
@ -1,3 +1,12 @@
|
|||
import sys
|
||||
from pathlib import PurePath
|
||||
|
||||
# For backwards compatibility, we import JSONField to have it available for import via
|
||||
# this compat module (https://github.com/graphql-python/graphene-django/issues/1428).
|
||||
# Django's JSONField is available in Django 3.2+ (the minimum version we support)
|
||||
from django.db.models import JSONField
|
||||
|
||||
|
||||
class MissingType:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
@ -7,19 +16,29 @@ try:
|
|||
# Postgres fields are only available in Django with psycopg2 installed
|
||||
# and we cannot have psycopg2 on PyPy
|
||||
from django.contrib.postgres.fields import (
|
||||
IntegerRangeField,
|
||||
ArrayField,
|
||||
HStoreField,
|
||||
JSONField as PGJSONField,
|
||||
IntegerRangeField,
|
||||
RangeField,
|
||||
)
|
||||
except ImportError:
|
||||
IntegerRangeField, ArrayField, HStoreField, PGJSONField, RangeField = (
|
||||
MissingType,
|
||||
) * 5
|
||||
IntegerRangeField, HStoreField, RangeField = (MissingType,) * 3
|
||||
|
||||
try:
|
||||
# JSONField is only available from Django 3.1
|
||||
from django.db.models import JSONField
|
||||
except ImportError:
|
||||
JSONField = MissingType
|
||||
# For unit tests we fake ArrayField using JSONFields
|
||||
if any(
|
||||
PurePath(sys.argv[0]).match(p)
|
||||
for p in [
|
||||
"**/pytest",
|
||||
"**/py.test",
|
||||
"**/pytest/__main__.py",
|
||||
]
|
||||
):
|
||||
|
||||
class ArrayField(JSONField):
|
||||
def __init__(self, *args, **kwargs):
|
||||
if len(args) > 0:
|
||||
self.base_field = args[0]
|
||||
super().__init__(**kwargs)
|
||||
|
||||
else:
|
||||
ArrayField = MissingType
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
from collections import OrderedDict
|
||||
from functools import singledispatch, wraps
|
||||
import inspect
|
||||
from collections.abc import Callable
|
||||
from functools import partial, singledispatch, wraps
|
||||
|
||||
from django.db import models
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.functional import Promise
|
||||
from django.utils.module_loading import import_string
|
||||
from graphql import GraphQLError
|
||||
|
||||
from graphene import (
|
||||
ID,
|
||||
|
@ -12,6 +14,7 @@ from graphene import (
|
|||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Decimal,
|
||||
Dynamic,
|
||||
Enum,
|
||||
Field,
|
||||
|
@ -21,12 +24,11 @@ from graphene import (
|
|||
NonNull,
|
||||
String,
|
||||
Time,
|
||||
Decimal,
|
||||
)
|
||||
from graphene.types.json import JSONString
|
||||
from graphene.types.resolver import get_default_resolver
|
||||
from graphene.types.scalars import BigInt
|
||||
from graphene.utils.str_converters import to_camel_case
|
||||
from graphql import GraphQLError
|
||||
|
||||
try:
|
||||
from graphql import assert_name
|
||||
|
@ -35,8 +37,8 @@ except ImportError:
|
|||
from graphql import assert_valid_name as assert_name
|
||||
from graphql.pyutils import register_description
|
||||
|
||||
from .compat import ArrayField, HStoreField, JSONField, PGJSONField, RangeField
|
||||
from .fields import DjangoListField, DjangoConnectionField
|
||||
from .compat import ArrayField, HStoreField, RangeField
|
||||
from .fields import DjangoConnectionField, DjangoListField
|
||||
from .settings import graphene_settings
|
||||
from .utils.str_converters import to_const
|
||||
|
||||
|
@ -70,8 +72,15 @@ def convert_choice_name(name):
|
|||
|
||||
def get_choices(choices):
|
||||
converted_names = []
|
||||
if isinstance(choices, OrderedDict):
|
||||
if isinstance(choices, Callable):
|
||||
choices = choices()
|
||||
|
||||
# In restframework==3.15.0, choices are not passed
|
||||
# as OrderedDict anymore, so it's safer to check
|
||||
# for a dict
|
||||
if isinstance(choices, dict):
|
||||
choices = choices.items()
|
||||
|
||||
for value, help_text in choices:
|
||||
if isinstance(help_text, (tuple, list)):
|
||||
yield from get_choices(help_text)
|
||||
|
@ -131,13 +140,17 @@ def convert_choice_field_to_enum(field, name=None):
|
|||
|
||||
|
||||
def convert_django_field_with_choices(
|
||||
field, registry=None, convert_choices_to_enum=True
|
||||
field, registry=None, convert_choices_to_enum=None
|
||||
):
|
||||
if registry is not None:
|
||||
converted = registry.get_converted_field(field)
|
||||
if converted:
|
||||
return converted
|
||||
choices = getattr(field, "choices", None)
|
||||
if convert_choices_to_enum is None:
|
||||
convert_choices_to_enum = bool(
|
||||
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CONVERT
|
||||
)
|
||||
if choices and convert_choices_to_enum:
|
||||
EnumCls = convert_choice_field_to_enum(field)
|
||||
required = not (field.blank or field.null)
|
||||
|
@ -159,9 +172,7 @@ def get_django_field_description(field):
|
|||
@singledispatch
|
||||
def convert_django_field(field, registry=None):
|
||||
raise Exception(
|
||||
"Don't know how to convert the Django field {} ({})".format(
|
||||
field, field.__class__
|
||||
)
|
||||
f"Don't know how to convert the Django field {field} ({field.__class__})"
|
||||
)
|
||||
|
||||
|
||||
|
@ -258,6 +269,10 @@ def convert_time_to_string(field, registry=None):
|
|||
|
||||
@convert_django_field.register(models.OneToOneRel)
|
||||
def convert_onetoone_field_to_djangomodel(field, registry=None):
|
||||
from graphene.utils.str_converters import to_snake_case
|
||||
|
||||
from .types import DjangoObjectType
|
||||
|
||||
model = field.related_model
|
||||
|
||||
def dynamic_type():
|
||||
|
@ -265,7 +280,55 @@ def convert_onetoone_field_to_djangomodel(field, registry=None):
|
|||
if not _type:
|
||||
return
|
||||
|
||||
return Field(_type, required=not field.null)
|
||||
class CustomField(Field):
|
||||
def wrap_resolve(self, parent_resolver):
|
||||
"""
|
||||
Implements a custom resolver which goes through the `get_node` method to ensure that
|
||||
it goes through the `get_queryset` method of the DjangoObjectType.
|
||||
"""
|
||||
resolver = super().wrap_resolve(parent_resolver)
|
||||
|
||||
# If `get_queryset` was not overridden in the DjangoObjectType
|
||||
# or if we explicitly bypass the `get_queryset` method,
|
||||
# we can just return the default resolver.
|
||||
if (
|
||||
_type.get_queryset.__func__
|
||||
is DjangoObjectType.get_queryset.__func__
|
||||
or getattr(resolver, "_bypass_get_queryset", False)
|
||||
):
|
||||
return resolver
|
||||
|
||||
def custom_resolver(root, info, **args):
|
||||
# Note: this function is used to resolve 1:1 relation fields
|
||||
|
||||
is_resolver_awaitable = inspect.iscoroutinefunction(resolver)
|
||||
|
||||
if is_resolver_awaitable:
|
||||
fk_obj = resolver(root, info, **args)
|
||||
# In case the resolver is a custom awaitable resolver that overwrites
|
||||
# the default Django resolver
|
||||
return fk_obj
|
||||
|
||||
field_name = to_snake_case(info.field_name)
|
||||
reversed_field_name = root.__class__._meta.get_field(
|
||||
field_name
|
||||
).remote_field.name
|
||||
try:
|
||||
return _type.get_queryset(
|
||||
_type._meta.model.objects.filter(
|
||||
**{reversed_field_name: root.pk}
|
||||
),
|
||||
info,
|
||||
).get()
|
||||
except _type._meta.model.DoesNotExist:
|
||||
return None
|
||||
|
||||
return custom_resolver
|
||||
|
||||
return CustomField(
|
||||
_type,
|
||||
required=not field.null,
|
||||
)
|
||||
|
||||
return Dynamic(dynamic_type)
|
||||
|
||||
|
@ -313,6 +376,10 @@ def convert_field_to_list_or_connection(field, registry=None):
|
|||
@convert_django_field.register(models.OneToOneField)
|
||||
@convert_django_field.register(models.ForeignKey)
|
||||
def convert_field_to_djangomodel(field, registry=None):
|
||||
from graphene.utils.str_converters import to_snake_case
|
||||
|
||||
from .types import DjangoObjectType
|
||||
|
||||
model = field.related_model
|
||||
|
||||
def dynamic_type():
|
||||
|
@ -320,7 +387,79 @@ def convert_field_to_djangomodel(field, registry=None):
|
|||
if not _type:
|
||||
return
|
||||
|
||||
return Field(
|
||||
class CustomField(Field):
|
||||
def wrap_resolve(self, parent_resolver):
|
||||
"""
|
||||
Implements a custom resolver which goes through the `get_node` method to ensure that
|
||||
it goes through the `get_queryset` method of the DjangoObjectType.
|
||||
"""
|
||||
resolver = super().wrap_resolve(parent_resolver)
|
||||
|
||||
# If `get_queryset` was not overridden in the DjangoObjectType
|
||||
# or if we explicitly bypass the `get_queryset` method,
|
||||
# we can just return the default resolver.
|
||||
if (
|
||||
_type.get_queryset.__func__
|
||||
is DjangoObjectType.get_queryset.__func__
|
||||
or getattr(resolver, "_bypass_get_queryset", False)
|
||||
):
|
||||
return resolver
|
||||
|
||||
def custom_resolver(root, info, **args):
|
||||
# Note: this function is used to resolve FK or 1:1 fields
|
||||
# it does not differentiate between custom-resolved fields
|
||||
# and default resolved fields.
|
||||
|
||||
# because this is a django foreign key or one-to-one field, the primary-key for
|
||||
# this node can be accessed from the root node.
|
||||
# ex: article.reporter_id
|
||||
|
||||
# get the name of the id field from the root's model
|
||||
field_name = to_snake_case(info.field_name)
|
||||
db_field_key = root.__class__._meta.get_field(field_name).attname
|
||||
if hasattr(root, db_field_key):
|
||||
# get the object's primary-key from root
|
||||
object_pk = getattr(root, db_field_key)
|
||||
else:
|
||||
return None
|
||||
|
||||
is_resolver_awaitable = inspect.iscoroutinefunction(resolver)
|
||||
|
||||
if is_resolver_awaitable:
|
||||
fk_obj = resolver(root, info, **args)
|
||||
# In case the resolver is a custom awaitable resolver that overwrites
|
||||
# the default Django resolver
|
||||
return fk_obj
|
||||
|
||||
instance_from_get_node = _type.get_node(info, object_pk)
|
||||
|
||||
if instance_from_get_node is None:
|
||||
# no instance to return
|
||||
return
|
||||
elif (
|
||||
isinstance(resolver, partial)
|
||||
and resolver.func is get_default_resolver()
|
||||
):
|
||||
return instance_from_get_node
|
||||
elif resolver is not get_default_resolver():
|
||||
# Default resolver is overridden
|
||||
# For optimization, add the instance to the resolver
|
||||
setattr(root, field_name, instance_from_get_node)
|
||||
# Explanation:
|
||||
# previously, _type.get_node` is called which results in at least one hit to the database.
|
||||
# But, if we did not pass the instance to the root, calling the resolver will result in
|
||||
# another call to get the instance which results in at least two database queries in total
|
||||
# to resolve this node only.
|
||||
# That's why the value of the object is set in the root so when the object is accessed
|
||||
# in the resolver (root.field_name) it does not access the database unless queried explicitly.
|
||||
fk_obj = resolver(root, info, **args)
|
||||
return fk_obj
|
||||
else:
|
||||
return instance_from_get_node
|
||||
|
||||
return custom_resolver
|
||||
|
||||
return CustomField(
|
||||
_type,
|
||||
description=get_django_field_description(field),
|
||||
required=not field.null,
|
||||
|
@ -346,9 +485,8 @@ def convert_postgres_array_to_list(field, registry=None):
|
|||
|
||||
|
||||
@convert_django_field.register(HStoreField)
|
||||
@convert_django_field.register(PGJSONField)
|
||||
@convert_django_field.register(JSONField)
|
||||
def convert_pg_and_json_field_to_string(field, registry=None):
|
||||
@convert_django_field.register(models.JSONField)
|
||||
def convert_json_field_to_string(field, registry=None):
|
||||
return JSONString(
|
||||
description=get_django_field_description(field), required=not field.null
|
||||
)
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
from django.db import connections
|
||||
|
||||
from promise import Promise
|
||||
|
||||
from .sql.tracking import unwrap_cursor, wrap_cursor
|
||||
from .exception.formating import wrap_exception
|
||||
from .sql.tracking import unwrap_cursor, wrap_cursor
|
||||
from .types import DjangoDebug
|
||||
|
||||
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import graphene
|
||||
import pytest
|
||||
|
||||
import graphene
|
||||
from graphene.relay import Node
|
||||
from graphene_django import DjangoConnectionField, DjangoObjectType
|
||||
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from graphene import List, ObjectType
|
||||
|
||||
from .sql.types import DjangoDebugSQL
|
||||
from .exception.types import DjangoDebugException
|
||||
from .sql.types import DjangoDebugSQL
|
||||
|
||||
|
||||
class DjangoDebug(ObjectType):
|
||||
|
|
|
@ -1,14 +1,12 @@
|
|||
from functools import partial
|
||||
|
||||
from django.db.models.query import QuerySet
|
||||
|
||||
from graphql_relay import (
|
||||
connection_from_array_slice,
|
||||
cursor_to_offset,
|
||||
get_offset_with_default,
|
||||
offset_to_cursor,
|
||||
)
|
||||
|
||||
from promise import Promise
|
||||
|
||||
from graphene import Int, NonNull
|
||||
|
@ -22,17 +20,20 @@ from .utils import maybe_queryset
|
|||
|
||||
class DjangoListField(Field):
|
||||
def __init__(self, _type, *args, **kwargs):
|
||||
from .types import DjangoObjectType
|
||||
|
||||
if isinstance(_type, NonNull):
|
||||
_type = _type.of_type
|
||||
|
||||
# Django would never return a Set of None vvvvvvv
|
||||
super().__init__(List(NonNull(_type)), *args, **kwargs)
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
from .types import DjangoObjectType
|
||||
|
||||
assert issubclass(
|
||||
self._underlying_type, DjangoObjectType
|
||||
), "DjangoListField only accepts DjangoObjectType types"
|
||||
), "DjangoListField only accepts DjangoObjectType types as underlying type"
|
||||
return super().type
|
||||
|
||||
@property
|
||||
def _underlying_type(self):
|
||||
|
@ -196,7 +197,7 @@ class DjangoConnectionField(ConnectionField):
|
|||
enforce_first_or_last,
|
||||
root,
|
||||
info,
|
||||
**args
|
||||
**args,
|
||||
):
|
||||
first = args.get("first")
|
||||
last = args.get("last")
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import warnings
|
||||
|
||||
from ..utils import DJANGO_FILTER_INSTALLED
|
||||
|
||||
if not DJANGO_FILTER_INSTALLED:
|
||||
|
|
|
@ -3,8 +3,8 @@ from functools import partial
|
|||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from graphene.types.enum import EnumType
|
||||
from graphene.types.argument import to_arguments
|
||||
from graphene.types.enum import EnumType
|
||||
from graphene.utils.str_converters import to_snake_case
|
||||
|
||||
from ..fields import DjangoConnectionField
|
||||
|
@ -36,7 +36,7 @@ class DjangoFilterConnectionField(DjangoConnectionField):
|
|||
extra_filter_meta=None,
|
||||
filterset_class=None,
|
||||
*args,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
):
|
||||
self._fields = fields
|
||||
self._provided_filterset_class = filterset_class
|
||||
|
@ -58,7 +58,7 @@ class DjangoFilterConnectionField(DjangoConnectionField):
|
|||
def filterset_class(self):
|
||||
if not self._filterset_class:
|
||||
fields = self._fields or self.node_type._meta.filter_fields
|
||||
meta = dict(model=self.model, fields=fields)
|
||||
meta = {"model": self.model, "fields": fields}
|
||||
if self._extra_filter_meta:
|
||||
meta.update(self._extra_filter_meta)
|
||||
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import warnings
|
||||
|
||||
from ...utils import DJANGO_FILTER_INSTALLED
|
||||
|
||||
if not DJANGO_FILTER_INSTALLED:
|
||||
|
|
|
@ -1,13 +1,36 @@
|
|||
from django_filters.constants import EMPTY_VALUES
|
||||
from django_filters.filters import FilterMethod
|
||||
|
||||
from .typed_filter import TypedFilter
|
||||
|
||||
|
||||
class ArrayFilterMethod(FilterMethod):
|
||||
def __call__(self, qs, value):
|
||||
if value is None:
|
||||
return qs
|
||||
return self.method(qs, self.f.field_name, value)
|
||||
|
||||
|
||||
class ArrayFilter(TypedFilter):
|
||||
"""
|
||||
Filter made for PostgreSQL ArrayField.
|
||||
"""
|
||||
|
||||
@TypedFilter.method.setter
|
||||
def method(self, value):
|
||||
"""
|
||||
Override method setter so that in case a custom `method` is provided
|
||||
(see documentation https://django-filter.readthedocs.io/en/stable/ref/filters.html#method),
|
||||
it doesn't fall back to checking if the value is in `EMPTY_VALUES` (from the `__call__` method
|
||||
of the `FilterMethod` class) and instead use our ArrayFilterMethod that consider empty lists as values.
|
||||
|
||||
Indeed when providing a `method` the `filter` method below is overridden and replaced by `FilterMethod(self)`
|
||||
which means that the validation of the empty value is made by the `FilterMethod.__call__` method instead.
|
||||
"""
|
||||
TypedFilter.method.fset(self, value)
|
||||
if value is not None:
|
||||
self.filter = ArrayFilterMethod(self)
|
||||
|
||||
def filter(self, qs, value):
|
||||
"""
|
||||
Override the default filter class to check first whether the list is
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
from django_filters import Filter, MultipleChoiceFilter
|
||||
|
||||
from graphql_relay.node.node import from_global_id
|
||||
|
||||
from ...forms import GlobalIDFormField, GlobalIDMultipleChoiceField
|
||||
|
|
|
@ -1,12 +1,36 @@
|
|||
from django_filters.filters import FilterMethod
|
||||
|
||||
from .typed_filter import TypedFilter
|
||||
|
||||
|
||||
class ListFilterMethod(FilterMethod):
|
||||
def __call__(self, qs, value):
|
||||
if value is None:
|
||||
return qs
|
||||
return self.method(qs, self.f.field_name, value)
|
||||
|
||||
|
||||
class ListFilter(TypedFilter):
|
||||
"""
|
||||
Filter that takes a list of value as input.
|
||||
It is for example used for `__in` filters.
|
||||
"""
|
||||
|
||||
@TypedFilter.method.setter
|
||||
def method(self, value):
|
||||
"""
|
||||
Override method setter so that in case a custom `method` is provided
|
||||
(see documentation https://django-filter.readthedocs.io/en/stable/ref/filters.html#method),
|
||||
it doesn't fall back to checking if the value is in `EMPTY_VALUES` (from the `__call__` method
|
||||
of the `FilterMethod` class) and instead use our ListFilterMethod that consider empty lists as values.
|
||||
|
||||
Indeed when providing a `method` the `filter` method below is overridden and replaced by `FilterMethod(self)`
|
||||
which means that the validation of the empty value is made by the `FilterMethod.__call__` method instead.
|
||||
"""
|
||||
TypedFilter.method.fset(self, value)
|
||||
if value is not None:
|
||||
self.filter = ListFilterMethod(self)
|
||||
|
||||
def filter(self, qs, value):
|
||||
"""
|
||||
Override the default filter class to check first whether the list is
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
import itertools
|
||||
|
||||
from django.db import models
|
||||
from django_filters.filterset import BaseFilterSet, FilterSet
|
||||
from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS
|
||||
from django_filters.filterset import (
|
||||
FILTER_FOR_DBFIELD_DEFAULTS,
|
||||
BaseFilterSet,
|
||||
FilterSet,
|
||||
)
|
||||
|
||||
from .filters import GlobalIDFilter, GlobalIDMultipleChoiceFilter
|
||||
|
||||
|
||||
GRAPHENE_FILTER_SET_OVERRIDES = {
|
||||
models.AutoField: {"filter_class": GlobalIDFilter},
|
||||
models.OneToOneField: {"filter_class": GlobalIDFilter},
|
||||
|
|
|
@ -1,15 +1,15 @@
|
|||
from unittest.mock import MagicMock
|
||||
import pytest
|
||||
from functools import reduce
|
||||
|
||||
import pytest
|
||||
from django.db import models
|
||||
from django.db.models.query import QuerySet
|
||||
from django_filters import filters
|
||||
from django_filters import FilterSet
|
||||
|
||||
import graphene
|
||||
from graphene.relay import Node
|
||||
from graphene_django import DjangoObjectType
|
||||
from graphene_django.filter import ArrayFilter
|
||||
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
||||
from graphene_django.filter import ArrayFilter, ListFilter
|
||||
|
||||
from ...compat import ArrayField
|
||||
|
||||
|
@ -25,15 +25,15 @@ else:
|
|||
)
|
||||
|
||||
|
||||
STORE = {"events": []}
|
||||
|
||||
|
||||
class Event(models.Model):
|
||||
name = models.CharField(max_length=50)
|
||||
tags = ArrayField(models.CharField(max_length=50))
|
||||
tag_ids = ArrayField(models.IntegerField())
|
||||
random_field = ArrayField(models.BooleanField())
|
||||
|
||||
def __repr__(self):
|
||||
return f"Event [{self.name}]"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def EventFilterSet():
|
||||
|
@ -44,10 +44,18 @@ def EventFilterSet():
|
|||
"name": ["exact", "contains"],
|
||||
}
|
||||
|
||||
# Those are actually usable with our Query fixture bellow
|
||||
# Those are actually usable with our Query fixture below
|
||||
tags__contains = ArrayFilter(field_name="tags", lookup_expr="contains")
|
||||
tags__overlap = ArrayFilter(field_name="tags", lookup_expr="overlap")
|
||||
tags = ArrayFilter(field_name="tags", lookup_expr="exact")
|
||||
tags__len = ArrayFilter(
|
||||
field_name="tags", lookup_expr="len", input_type=graphene.Int
|
||||
)
|
||||
tags__len__in = ArrayFilter(
|
||||
field_name="tags",
|
||||
method="tags__len__in_filter",
|
||||
input_type=graphene.List(graphene.Int),
|
||||
)
|
||||
|
||||
# Those are actually not usable and only to check type declarations
|
||||
tags_ids__contains = ArrayFilter(field_name="tag_ids", lookup_expr="contains")
|
||||
|
@ -61,6 +69,14 @@ def EventFilterSet():
|
|||
)
|
||||
random_field = ArrayFilter(field_name="random_field", lookup_expr="exact")
|
||||
|
||||
def tags__len__in_filter(self, queryset, _name, value):
|
||||
if not value:
|
||||
return queryset.none()
|
||||
return reduce(
|
||||
lambda q1, q2: q1.union(q2),
|
||||
[queryset.filter(tags__len=v) for v in value],
|
||||
).distinct()
|
||||
|
||||
return EventFilterSet
|
||||
|
||||
|
||||
|
@ -83,68 +99,94 @@ def Query(EventType):
|
|||
we are running unit tests in sqlite which does not have ArrayFields.
|
||||
"""
|
||||
|
||||
events = [
|
||||
Event(name="Live Show", tags=["concert", "music", "rock"]),
|
||||
Event(name="Musical", tags=["movie", "music"]),
|
||||
Event(name="Ballet", tags=["concert", "dance"]),
|
||||
Event(name="Speech", tags=[]),
|
||||
]
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
events = DjangoFilterConnectionField(EventType)
|
||||
|
||||
def resolve_events(self, info, **kwargs):
|
||||
events = [
|
||||
Event(name="Live Show", tags=["concert", "music", "rock"]),
|
||||
Event(name="Musical", tags=["movie", "music"]),
|
||||
Event(name="Ballet", tags=["concert", "dance"]),
|
||||
Event(name="Speech", tags=[]),
|
||||
]
|
||||
class FakeQuerySet(QuerySet):
|
||||
def __init__(self, model=None):
|
||||
self.model = Event
|
||||
self.__store = list(events)
|
||||
|
||||
STORE["events"] = events
|
||||
def all(self):
|
||||
return self
|
||||
|
||||
m_queryset = MagicMock(spec=QuerySet)
|
||||
m_queryset.model = Event
|
||||
|
||||
def filter_events(**kwargs):
|
||||
if "tags__contains" in kwargs:
|
||||
STORE["events"] = list(
|
||||
filter(
|
||||
lambda e: set(kwargs["tags__contains"]).issubset(
|
||||
set(e.tags)
|
||||
),
|
||||
STORE["events"],
|
||||
def filter(self, **kwargs):
|
||||
queryset = FakeQuerySet()
|
||||
queryset.__store = list(self.__store)
|
||||
if "tags__contains" in kwargs:
|
||||
queryset.__store = list(
|
||||
filter(
|
||||
lambda e: set(kwargs["tags__contains"]).issubset(
|
||||
set(e.tags)
|
||||
),
|
||||
queryset.__store,
|
||||
)
|
||||
)
|
||||
)
|
||||
if "tags__overlap" in kwargs:
|
||||
STORE["events"] = list(
|
||||
filter(
|
||||
lambda e: not set(kwargs["tags__overlap"]).isdisjoint(
|
||||
set(e.tags)
|
||||
),
|
||||
STORE["events"],
|
||||
if "tags__overlap" in kwargs:
|
||||
queryset.__store = list(
|
||||
filter(
|
||||
lambda e: not set(kwargs["tags__overlap"]).isdisjoint(
|
||||
set(e.tags)
|
||||
),
|
||||
queryset.__store,
|
||||
)
|
||||
)
|
||||
)
|
||||
if "tags__exact" in kwargs:
|
||||
STORE["events"] = list(
|
||||
filter(
|
||||
lambda e: set(kwargs["tags__exact"]) == set(e.tags),
|
||||
STORE["events"],
|
||||
if "tags__exact" in kwargs:
|
||||
queryset.__store = list(
|
||||
filter(
|
||||
lambda e: set(kwargs["tags__exact"]) == set(e.tags),
|
||||
queryset.__store,
|
||||
)
|
||||
)
|
||||
)
|
||||
if "tags__len" in kwargs:
|
||||
queryset.__store = list(
|
||||
filter(
|
||||
lambda e: len(e.tags) == kwargs["tags__len"],
|
||||
queryset.__store,
|
||||
)
|
||||
)
|
||||
return queryset
|
||||
|
||||
def mock_queryset_filter(*args, **kwargs):
|
||||
filter_events(**kwargs)
|
||||
return m_queryset
|
||||
def union(self, *args):
|
||||
queryset = FakeQuerySet()
|
||||
queryset.__store = self.__store
|
||||
for arg in args:
|
||||
queryset.__store += arg.__store
|
||||
return queryset
|
||||
|
||||
def mock_queryset_none(*args, **kwargs):
|
||||
STORE["events"] = []
|
||||
return m_queryset
|
||||
def none(self):
|
||||
queryset = FakeQuerySet()
|
||||
queryset.__store = []
|
||||
return queryset
|
||||
|
||||
def mock_queryset_count(*args, **kwargs):
|
||||
return len(STORE["events"])
|
||||
def count(self):
|
||||
return len(self.__store)
|
||||
|
||||
m_queryset.all.return_value = m_queryset
|
||||
m_queryset.filter.side_effect = mock_queryset_filter
|
||||
m_queryset.none.side_effect = mock_queryset_none
|
||||
m_queryset.count.side_effect = mock_queryset_count
|
||||
m_queryset.__getitem__.side_effect = lambda index: STORE[
|
||||
"events"
|
||||
].__getitem__(index)
|
||||
def distinct(self):
|
||||
queryset = FakeQuerySet()
|
||||
queryset.__store = []
|
||||
for event in self.__store:
|
||||
if event not in queryset.__store:
|
||||
queryset.__store.append(event)
|
||||
queryset.__store = sorted(queryset.__store, key=lambda e: e.name)
|
||||
return queryset
|
||||
|
||||
return m_queryset
|
||||
def __getitem__(self, index):
|
||||
return self.__store[index]
|
||||
|
||||
return FakeQuerySet()
|
||||
|
||||
return Query
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def schema(Query):
|
||||
return graphene.Schema(query=Query)
|
||||
|
|
|
@ -1,18 +1,14 @@
|
|||
import pytest
|
||||
|
||||
from graphene import Schema
|
||||
|
||||
from ...compat import ArrayField, MissingType
|
||||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_contains_multiple(Query):
|
||||
def test_array_field_contains_multiple(schema):
|
||||
"""
|
||||
Test contains filter on a array field of string.
|
||||
"""
|
||||
|
||||
schema = Schema(query=Query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Contains: ["concert", "music"]) {
|
||||
|
@ -32,13 +28,11 @@ def test_array_field_contains_multiple(Query):
|
|||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_contains_one(Query):
|
||||
def test_array_field_contains_one(schema):
|
||||
"""
|
||||
Test contains filter on a array field of string.
|
||||
"""
|
||||
|
||||
schema = Schema(query=Query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Contains: ["music"]) {
|
||||
|
@ -59,13 +53,11 @@ def test_array_field_contains_one(Query):
|
|||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_contains_empty_list(Query):
|
||||
def test_array_field_contains_empty_list(schema):
|
||||
"""
|
||||
Test contains filter on a array field of string.
|
||||
"""
|
||||
|
||||
schema = Schema(query=Query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Contains: []) {
|
||||
|
|
186
graphene_django/filter/tests/test_array_field_custom_filter.py
Normal file
186
graphene_django/filter/tests/test_array_field_custom_filter.py
Normal file
|
@ -0,0 +1,186 @@
|
|||
import pytest
|
||||
|
||||
from ...compat import ArrayField, MissingType
|
||||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_len_filter(schema):
|
||||
query = """
|
||||
query {
|
||||
events (tags_Len: 2) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["events"]["edges"] == [
|
||||
{"node": {"name": "Musical"}},
|
||||
{"node": {"name": "Ballet"}},
|
||||
]
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Len: 0) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["events"]["edges"] == [
|
||||
{"node": {"name": "Speech"}},
|
||||
]
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Len: 10) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["events"]["edges"] == []
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Len: "2") {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0].message == 'Int cannot represent non-integer value: "2"'
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Len: True) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0].message == "Int cannot represent non-integer value: True"
|
||||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_custom_filter(schema):
|
||||
query = """
|
||||
query {
|
||||
events (tags_Len_In: 2) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["events"]["edges"] == [
|
||||
{"node": {"name": "Ballet"}},
|
||||
{"node": {"name": "Musical"}},
|
||||
]
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Len_In: [0, 2]) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["events"]["edges"] == [
|
||||
{"node": {"name": "Ballet"}},
|
||||
{"node": {"name": "Musical"}},
|
||||
{"node": {"name": "Speech"}},
|
||||
]
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Len_In: [10]) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["events"]["edges"] == []
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Len_In: []) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["events"]["edges"] == []
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Len_In: "12") {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0].message == 'Int cannot represent non-integer value: "12"'
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Len_In: True) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0].message == "Int cannot represent non-integer value: True"
|
|
@ -1,18 +1,14 @@
|
|||
import pytest
|
||||
|
||||
from graphene import Schema
|
||||
|
||||
from ...compat import ArrayField, MissingType
|
||||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_exact_no_match(Query):
|
||||
def test_array_field_exact_no_match(schema):
|
||||
"""
|
||||
Test exact filter on a array field of string.
|
||||
"""
|
||||
|
||||
schema = Schema(query=Query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags: ["concert", "music"]) {
|
||||
|
@ -30,13 +26,11 @@ def test_array_field_exact_no_match(Query):
|
|||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_exact_match(Query):
|
||||
def test_array_field_exact_match(schema):
|
||||
"""
|
||||
Test exact filter on a array field of string.
|
||||
"""
|
||||
|
||||
schema = Schema(query=Query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags: ["movie", "music"]) {
|
||||
|
@ -56,13 +50,11 @@ def test_array_field_exact_match(Query):
|
|||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_exact_empty_list(Query):
|
||||
def test_array_field_exact_empty_list(schema):
|
||||
"""
|
||||
Test exact filter on a array field of string.
|
||||
"""
|
||||
|
||||
schema = Schema(query=Query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags: []) {
|
||||
|
@ -82,11 +74,10 @@ def test_array_field_exact_empty_list(Query):
|
|||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_filter_schema_type(Query):
|
||||
def test_array_field_filter_schema_type(schema):
|
||||
"""
|
||||
Check that the type in the filter is an array field like on the object type.
|
||||
"""
|
||||
schema = Schema(query=Query)
|
||||
schema_str = str(schema)
|
||||
|
||||
assert (
|
||||
|
@ -112,6 +103,8 @@ def test_array_field_filter_schema_type(Query):
|
|||
"tags_Contains": "[String!]",
|
||||
"tags_Overlap": "[String!]",
|
||||
"tags": "[String!]",
|
||||
"tags_Len": "Int",
|
||||
"tags_Len_In": "[Int]",
|
||||
"tagsIds_Contains": "[Int!]",
|
||||
"tagsIds_Overlap": "[Int!]",
|
||||
"tagsIds": "[Int!]",
|
||||
|
|
|
@ -1,18 +1,14 @@
|
|||
import pytest
|
||||
|
||||
from graphene import Schema
|
||||
|
||||
from ...compat import ArrayField, MissingType
|
||||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_overlap_multiple(Query):
|
||||
def test_array_field_overlap_multiple(schema):
|
||||
"""
|
||||
Test overlap filter on a array field of string.
|
||||
"""
|
||||
|
||||
schema = Schema(query=Query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Overlap: ["concert", "music"]) {
|
||||
|
@ -34,13 +30,11 @@ def test_array_field_overlap_multiple(Query):
|
|||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_overlap_one(Query):
|
||||
def test_array_field_overlap_one(schema):
|
||||
"""
|
||||
Test overlap filter on a array field of string.
|
||||
"""
|
||||
|
||||
schema = Schema(query=Query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Overlap: ["music"]) {
|
||||
|
@ -61,13 +55,11 @@ def test_array_field_overlap_one(Query):
|
|||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_overlap_empty_list(Query):
|
||||
def test_array_field_overlap_empty_list(schema):
|
||||
"""
|
||||
Test overlap filter on a array field of string.
|
||||
"""
|
||||
|
||||
schema = Schema(query=Query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Overlap: []) {
|
||||
|
|
|
@ -2,8 +2,7 @@ import pytest
|
|||
|
||||
import graphene
|
||||
from graphene.relay import Node
|
||||
|
||||
from graphene_django import DjangoObjectType, DjangoConnectionField
|
||||
from graphene_django import DjangoConnectionField, DjangoObjectType
|
||||
from graphene_django.tests.models import Article, Reporter
|
||||
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
||||
|
||||
|
|
|
@ -19,8 +19,8 @@ if DJANGO_FILTER_INSTALLED:
|
|||
from django_filters import FilterSet, NumberFilter, OrderingFilter
|
||||
|
||||
from graphene_django.filter import (
|
||||
GlobalIDFilter,
|
||||
DjangoFilterConnectionField,
|
||||
GlobalIDFilter,
|
||||
GlobalIDMultipleChoiceFilter,
|
||||
)
|
||||
from graphene_django.filter.tests.filters import (
|
||||
|
@ -222,7 +222,7 @@ def test_filter_filterset_information_on_meta_related():
|
|||
reporter = Field(ReporterFilterNode)
|
||||
article = Field(ArticleFilterNode)
|
||||
|
||||
schema = Schema(query=Query)
|
||||
Schema(query=Query)
|
||||
articles_field = ReporterFilterNode._meta.fields["articles"].get_type()
|
||||
assert_arguments(articles_field, "headline", "reporter")
|
||||
assert_not_orderable(articles_field)
|
||||
|
@ -294,7 +294,7 @@ def test_filter_filterset_class_information_on_meta_related():
|
|||
reporter = Field(ReporterFilterNode)
|
||||
article = Field(ArticleFilterNode)
|
||||
|
||||
schema = Schema(query=Query)
|
||||
Schema(query=Query)
|
||||
articles_field = ReporterFilterNode._meta.fields["articles"].get_type()
|
||||
assert_arguments(articles_field, "headline", "reporter")
|
||||
assert_not_orderable(articles_field)
|
||||
|
@ -789,7 +789,7 @@ def test_order_by():
|
|||
|
||||
query = """
|
||||
query NodeFilteringQuery {
|
||||
allReporters(orderBy: "-firtsnaMe") {
|
||||
allReporters(orderBy: "-firstname") {
|
||||
edges {
|
||||
node {
|
||||
firstName
|
||||
|
@ -802,7 +802,7 @@ def test_order_by():
|
|||
assert result.errors
|
||||
|
||||
|
||||
def test_order_by_is_perserved():
|
||||
def test_order_by_is_preserved():
|
||||
class ReporterType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Reporter
|
||||
|
@ -1186,7 +1186,7 @@ def test_filter_filterset_based_on_mixin():
|
|||
first_name="Adam", last_name="Doe", email="adam@doe.com"
|
||||
)
|
||||
|
||||
article_2 = Article.objects.create(
|
||||
Article.objects.create(
|
||||
headline="Good Bye",
|
||||
reporter=reporter_2,
|
||||
editor=reporter_2,
|
||||
|
|
|
@ -1,14 +1,16 @@
|
|||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from django_filters import (
|
||||
FilterSet,
|
||||
rest_framework as filters,
|
||||
)
|
||||
|
||||
from django_filters import FilterSet
|
||||
from django_filters import rest_framework as filters
|
||||
from graphene import ObjectType, Schema
|
||||
from graphene.relay import Node
|
||||
from graphene_django import DjangoObjectType
|
||||
from graphene_django.tests.models import Pet, Person, Reporter, Article, Film
|
||||
from graphene_django.filter.tests.filters import ArticleFilter
|
||||
from graphene_django.tests.models import Article, Film, Person, Pet, Reporter
|
||||
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
||||
|
||||
pytestmark = []
|
||||
|
@ -348,9 +350,9 @@ def test_fk_id_in_filter(query):
|
|||
|
||||
schema = Schema(query=query)
|
||||
|
||||
query = """
|
||||
query = f"""
|
||||
query {{
|
||||
articles (reporter_In: [{}, {}]) {{
|
||||
articles (reporter_In: [{john_doe.id}, {jean_bon.id}]) {{
|
||||
edges {{
|
||||
node {{
|
||||
headline
|
||||
|
@ -361,10 +363,7 @@ def test_fk_id_in_filter(query):
|
|||
}}
|
||||
}}
|
||||
}}
|
||||
""".format(
|
||||
john_doe.id,
|
||||
jean_bon.id,
|
||||
)
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["articles"]["edges"] == [
|
||||
|
|
|
@ -1,8 +1,7 @@
|
|||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from django_filters import FilterSet
|
||||
from django_filters import rest_framework as filters
|
||||
from graphene import ObjectType, Schema
|
||||
from graphene.relay import Node
|
||||
from graphene_django import DjangoObjectType
|
||||
|
|
|
@ -1,10 +1,12 @@
|
|||
import pytest
|
||||
import operator
|
||||
from functools import reduce
|
||||
|
||||
import pytest
|
||||
from django.db.models import Q
|
||||
from django_filters import FilterSet
|
||||
|
||||
import graphene
|
||||
from graphene.relay import Node
|
||||
|
||||
from graphene_django import DjangoObjectType
|
||||
from graphene_django.tests.models import Article, Reporter
|
||||
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
||||
|
@ -14,8 +16,8 @@ pytestmark = []
|
|||
if DJANGO_FILTER_INSTALLED:
|
||||
from graphene_django.filter import (
|
||||
DjangoFilterConnectionField,
|
||||
TypedFilter,
|
||||
ListFilter,
|
||||
TypedFilter,
|
||||
)
|
||||
else:
|
||||
pytestmark.append(
|
||||
|
@ -46,6 +48,10 @@ def schema():
|
|||
only_first = TypedFilter(
|
||||
input_type=graphene.Boolean, method="only_first_filter"
|
||||
)
|
||||
headline_search = ListFilter(
|
||||
method="headline_search_filter",
|
||||
input_type=graphene.List(graphene.String),
|
||||
)
|
||||
|
||||
def first_n_filter(self, queryset, _name, value):
|
||||
return queryset[:value]
|
||||
|
@ -56,6 +62,13 @@ def schema():
|
|||
else:
|
||||
return queryset
|
||||
|
||||
def headline_search_filter(self, queryset, _name, value):
|
||||
if not value:
|
||||
return queryset.none()
|
||||
return queryset.filter(
|
||||
reduce(operator.or_, [Q(headline__icontains=v) for v in value])
|
||||
)
|
||||
|
||||
class ArticleType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Article
|
||||
|
@ -89,6 +102,7 @@ def test_typed_filter_schema(schema):
|
|||
"lang_InStr": "[String]",
|
||||
"firstN": "Int",
|
||||
"onlyFirst": "Boolean",
|
||||
"headlineSearch": "[String]",
|
||||
}
|
||||
|
||||
all_articles_filters = (
|
||||
|
@ -106,24 +120,7 @@ def test_typed_filters_work(schema):
|
|||
Article.objects.create(headline="A", reporter=reporter, editor=reporter, lang="es")
|
||||
Article.objects.create(headline="B", reporter=reporter, editor=reporter, lang="es")
|
||||
Article.objects.create(headline="C", reporter=reporter, editor=reporter, lang="en")
|
||||
|
||||
query = "query { articles (lang_In: [ES]) { edges { node { headline } } } }"
|
||||
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["articles"]["edges"] == [
|
||||
{"node": {"headline": "A"}},
|
||||
{"node": {"headline": "B"}},
|
||||
]
|
||||
|
||||
query = 'query { articles (lang_InStr: ["es"]) { edges { node { headline } } } }'
|
||||
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["articles"]["edges"] == [
|
||||
{"node": {"headline": "A"}},
|
||||
{"node": {"headline": "B"}},
|
||||
]
|
||||
Article.objects.create(headline="AB", reporter=reporter, editor=reporter, lang="es")
|
||||
|
||||
query = 'query { articles (lang_Contains: "n") { edges { node { headline } } } }'
|
||||
|
||||
|
@ -139,7 +136,7 @@ def test_typed_filters_work(schema):
|
|||
assert not result.errors
|
||||
assert result.data["articles"]["edges"] == [
|
||||
{"node": {"headline": "A"}},
|
||||
{"node": {"headline": "B"}},
|
||||
{"node": {"headline": "AB"}},
|
||||
]
|
||||
|
||||
query = "query { articles (onlyFirst: true) { edges { node { headline } } } }"
|
||||
|
@ -149,3 +146,86 @@ def test_typed_filters_work(schema):
|
|||
assert result.data["articles"]["edges"] == [
|
||||
{"node": {"headline": "A"}},
|
||||
]
|
||||
|
||||
|
||||
def test_list_filters_work(schema):
|
||||
reporter = Reporter.objects.create(first_name="John", last_name="Doe", email="")
|
||||
Article.objects.create(headline="A", reporter=reporter, editor=reporter, lang="es")
|
||||
Article.objects.create(headline="B", reporter=reporter, editor=reporter, lang="es")
|
||||
Article.objects.create(headline="C", reporter=reporter, editor=reporter, lang="en")
|
||||
Article.objects.create(headline="AB", reporter=reporter, editor=reporter, lang="es")
|
||||
|
||||
query = "query { articles (lang_In: [ES]) { edges { node { headline } } } }"
|
||||
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["articles"]["edges"] == [
|
||||
{"node": {"headline": "A"}},
|
||||
{"node": {"headline": "AB"}},
|
||||
{"node": {"headline": "B"}},
|
||||
]
|
||||
|
||||
query = 'query { articles (lang_InStr: ["es"]) { edges { node { headline } } } }'
|
||||
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["articles"]["edges"] == [
|
||||
{"node": {"headline": "A"}},
|
||||
{"node": {"headline": "AB"}},
|
||||
{"node": {"headline": "B"}},
|
||||
]
|
||||
|
||||
query = "query { articles (lang_InStr: []) { edges { node { headline } } } }"
|
||||
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["articles"]["edges"] == []
|
||||
|
||||
query = "query { articles (lang_InStr: null) { edges { node { headline } } } }"
|
||||
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["articles"]["edges"] == [
|
||||
{"node": {"headline": "A"}},
|
||||
{"node": {"headline": "AB"}},
|
||||
{"node": {"headline": "B"}},
|
||||
{"node": {"headline": "C"}},
|
||||
]
|
||||
|
||||
query = 'query { articles (headlineSearch: ["a", "B"]) { edges { node { headline } } } }'
|
||||
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["articles"]["edges"] == [
|
||||
{"node": {"headline": "A"}},
|
||||
{"node": {"headline": "AB"}},
|
||||
{"node": {"headline": "B"}},
|
||||
]
|
||||
|
||||
query = "query { articles (headlineSearch: []) { edges { node { headline } } } }"
|
||||
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["articles"]["edges"] == []
|
||||
|
||||
query = "query { articles (headlineSearch: null) { edges { node { headline } } } }"
|
||||
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["articles"]["edges"] == [
|
||||
{"node": {"headline": "A"}},
|
||||
{"node": {"headline": "AB"}},
|
||||
{"node": {"headline": "B"}},
|
||||
{"node": {"headline": "C"}},
|
||||
]
|
||||
|
||||
query = 'query { articles (headlineSearch: [""]) { edges { node { headline } } } }'
|
||||
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["articles"]["edges"] == [
|
||||
{"node": {"headline": "A"}},
|
||||
{"node": {"headline": "AB"}},
|
||||
{"node": {"headline": "B"}},
|
||||
{"node": {"headline": "C"}},
|
||||
]
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import graphene
|
||||
from django import forms
|
||||
from django_filters.utils import get_model_field, get_field_parts
|
||||
from django_filters.filters import Filter, BaseCSVFilter
|
||||
from .filters import ArrayFilter, ListFilter, RangeFilter, TypedFilter
|
||||
from .filterset import custom_filterset_factory, setup_filterset
|
||||
from django_filters.utils import get_model_field
|
||||
|
||||
import graphene
|
||||
|
||||
from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField
|
||||
from .filters import ListFilter, RangeFilter, TypedFilter
|
||||
from .filterset import custom_filterset_factory, setup_filterset
|
||||
|
||||
|
||||
def get_field_type(registry, model, field_name):
|
||||
|
@ -42,7 +43,7 @@ def get_filtering_args_from_filterset(filterset_class, type):
|
|||
isinstance(filter_field, TypedFilter)
|
||||
and filter_field.input_type is not None
|
||||
):
|
||||
# First check if the filter input type has been explicitely given
|
||||
# First check if the filter input type has been explicitly given
|
||||
field_type = filter_field.input_type
|
||||
else:
|
||||
if name not in filterset_class.declared_filters or isinstance(
|
||||
|
@ -50,7 +51,7 @@ def get_filtering_args_from_filterset(filterset_class, type):
|
|||
):
|
||||
# Get the filter field for filters that are no explicitly declared.
|
||||
if filter_type == "isnull":
|
||||
field = graphene.Boolean(required=required)
|
||||
field_type = graphene.Boolean
|
||||
else:
|
||||
model_field = get_model_field(model, filter_field.field_name)
|
||||
|
||||
|
@ -147,7 +148,7 @@ def replace_csv_filters(filterset_class):
|
|||
label=filter_field.label,
|
||||
method=filter_field.method,
|
||||
exclude=filter_field.exclude,
|
||||
**filter_field.extra
|
||||
**filter_field.extra,
|
||||
)
|
||||
elif filter_type == "range":
|
||||
filterset_class.base_filters[name] = RangeFilter(
|
||||
|
@ -156,5 +157,5 @@ def replace_csv_filters(filterset_class):
|
|||
label=filter_field.label,
|
||||
method=filter_field.method,
|
||||
exclude=filter_field.exclude,
|
||||
**filter_field.extra
|
||||
**filter_field.extra,
|
||||
)
|
||||
|
|
|
@ -5,15 +5,15 @@ from django.core.exceptions import ImproperlyConfigured
|
|||
|
||||
from graphene import (
|
||||
ID,
|
||||
UUID,
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Decimal,
|
||||
Float,
|
||||
Int,
|
||||
List,
|
||||
String,
|
||||
UUID,
|
||||
Date,
|
||||
DateTime,
|
||||
Time,
|
||||
)
|
||||
|
||||
|
@ -27,8 +27,8 @@ def get_form_field_description(field):
|
|||
@singledispatch
|
||||
def convert_form_field(field):
|
||||
raise ImproperlyConfigured(
|
||||
"Don't know how to convert the Django form field %s (%s) "
|
||||
"to Graphene type" % (field, field.__class__)
|
||||
f"Don't know how to convert the Django form field {field} ({field.__class__}) "
|
||||
"to Graphene type"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -3,7 +3,6 @@ import binascii
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.forms import CharField, Field, MultipleChoiceField
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from graphql_relay import from_global_id
|
||||
|
||||
|
||||
|
|
|
@ -23,8 +23,7 @@ def fields_for_form(form, only_fields, exclude_fields):
|
|||
for name, field in form.fields.items():
|
||||
is_not_in_only = only_fields and name not in only_fields
|
||||
is_excluded = (
|
||||
name
|
||||
in exclude_fields # or
|
||||
name in exclude_fields # or
|
||||
# name in already_created_fields
|
||||
)
|
||||
|
||||
|
|
|
@ -1,19 +1,18 @@
|
|||
from django import forms
|
||||
from pytest import raises
|
||||
|
||||
import graphene
|
||||
from graphene import (
|
||||
String,
|
||||
Int,
|
||||
Boolean,
|
||||
Decimal,
|
||||
Float,
|
||||
ID,
|
||||
UUID,
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Decimal,
|
||||
Float,
|
||||
Int,
|
||||
List,
|
||||
NonNull,
|
||||
DateTime,
|
||||
Date,
|
||||
String,
|
||||
Time,
|
||||
)
|
||||
|
||||
|
|
333
graphene_django/forms/tests/test_djangoinputobject.py
Normal file
333
graphene_django/forms/tests/test_djangoinputobject.py
Normal file
|
@ -0,0 +1,333 @@
|
|||
from django import forms
|
||||
from pytest import raises
|
||||
|
||||
import graphene
|
||||
from graphene_django import DjangoObjectType
|
||||
|
||||
from ...tests.models import CHOICES, Film, Reporter
|
||||
from ..types import DjangoFormInputObjectType
|
||||
|
||||
# Reporter a_choice CHOICES = ((1, "this"), (2, _("that")))
|
||||
THIS = CHOICES[0][0]
|
||||
THIS_ON_CLIENT_CONVERTED = "A_1"
|
||||
|
||||
# Film genre choices=[("do", "Documentary"), ("ac", "Action"), ("ot", "Other")],
|
||||
DOCUMENTARY = "do"
|
||||
DOCUMENTARY_ON_CLIENT_CONVERTED = "DO"
|
||||
|
||||
|
||||
class FilmForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Film
|
||||
exclude = ()
|
||||
|
||||
|
||||
class ReporterType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Reporter
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class ReporterForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = Reporter
|
||||
exclude = ("pets", "email", "fans")
|
||||
|
||||
|
||||
class MyForm(forms.Form):
|
||||
text_field = forms.CharField()
|
||||
int_field = forms.IntegerField()
|
||||
|
||||
|
||||
def test_needs_form_class():
|
||||
with raises(Exception) as exc:
|
||||
|
||||
class MyInputType(DjangoFormInputObjectType):
|
||||
pass
|
||||
|
||||
assert exc.value.args[0] == "form_class is required for DjangoFormInputObjectType"
|
||||
|
||||
|
||||
def test_type_from_modelform_has_input_fields():
|
||||
class ReporterInputType(DjangoFormInputObjectType):
|
||||
class Meta:
|
||||
form_class = ReporterForm
|
||||
only_fields = ("first_name", "last_name", "a_choice")
|
||||
|
||||
fields = ["first_name", "last_name", "a_choice", "id"]
|
||||
assert all(f in ReporterInputType._meta.fields for f in fields)
|
||||
|
||||
|
||||
def test_type_from_form_has_input_fields():
|
||||
class MyFormInputType(DjangoFormInputObjectType):
|
||||
class Meta:
|
||||
form_class = MyForm
|
||||
|
||||
fields = ["text_field", "int_field", "id"]
|
||||
assert all(f in MyFormInputType._meta.fields for f in fields)
|
||||
|
||||
|
||||
def test_type_custom_id_field():
|
||||
class MyFormInputType(DjangoFormInputObjectType):
|
||||
class Meta:
|
||||
form_class = MyForm
|
||||
add_id_field_name = "pk"
|
||||
|
||||
fields = ["text_field", "int_field", "pk"]
|
||||
assert all(f in MyFormInputType._meta.fields for f in fields)
|
||||
assert MyFormInputType._meta.fields["pk"].type is graphene.ID
|
||||
|
||||
|
||||
def test_type_custom_id_field_type():
|
||||
class MyFormInputType(DjangoFormInputObjectType):
|
||||
class Meta:
|
||||
form_class = MyForm
|
||||
add_id_field_name = "pk"
|
||||
add_id_field_type = graphene.String(required=False)
|
||||
|
||||
fields = ["text_field", "int_field", "pk"]
|
||||
assert all(f in MyFormInputType._meta.fields for f in fields)
|
||||
assert MyFormInputType._meta.fields["pk"].type is graphene.String
|
||||
|
||||
|
||||
class MockQuery(graphene.ObjectType):
|
||||
a = graphene.String()
|
||||
|
||||
|
||||
def test_mutation_with_form_djangoforminputtype():
|
||||
class MyFormInputType(DjangoFormInputObjectType):
|
||||
class Meta:
|
||||
form_class = MyForm
|
||||
|
||||
class MyFormMutation(graphene.Mutation):
|
||||
class Arguments:
|
||||
form_data = MyFormInputType(required=True)
|
||||
|
||||
result = graphene.Boolean()
|
||||
|
||||
def mutate(_root, _info, form_data):
|
||||
form = MyForm(data=form_data)
|
||||
if form.is_valid():
|
||||
result = form.cleaned_data == {
|
||||
"text_field": "text",
|
||||
"int_field": 777,
|
||||
}
|
||||
return MyFormMutation(result=result)
|
||||
return MyFormMutation(result=False)
|
||||
|
||||
class Mutation(graphene.ObjectType):
|
||||
myForm_mutation = MyFormMutation.Field()
|
||||
|
||||
schema = graphene.Schema(query=MockQuery, mutation=Mutation)
|
||||
|
||||
result = schema.execute(
|
||||
""" mutation MyFormMutation($formData: MyFormInputType!) {
|
||||
myFormMutation(formData: $formData) {
|
||||
result
|
||||
}
|
||||
}
|
||||
""",
|
||||
variable_values={"formData": {"textField": "text", "intField": 777}},
|
||||
)
|
||||
assert result.errors is None
|
||||
assert result.data == {"myFormMutation": {"result": True}}
|
||||
|
||||
|
||||
def test_mutation_with_modelform_djangoforminputtype():
|
||||
class ReporterInputType(DjangoFormInputObjectType):
|
||||
class Meta:
|
||||
form_class = ReporterForm
|
||||
object_type = ReporterType
|
||||
only_fields = ("first_name", "last_name", "a_choice")
|
||||
|
||||
class ReporterMutation(graphene.Mutation):
|
||||
class Arguments:
|
||||
reporter_data = ReporterInputType(required=True)
|
||||
|
||||
result = graphene.Field(ReporterType)
|
||||
|
||||
def mutate(_root, _info, reporter_data):
|
||||
reporter = Reporter.objects.get(pk=reporter_data.id)
|
||||
form = ReporterForm(data=reporter_data, instance=reporter)
|
||||
if form.is_valid():
|
||||
reporter = form.save()
|
||||
return ReporterMutation(result=reporter)
|
||||
|
||||
return ReporterMutation(result=None)
|
||||
|
||||
class Mutation(graphene.ObjectType):
|
||||
report_mutation = ReporterMutation.Field()
|
||||
|
||||
schema = graphene.Schema(query=MockQuery, mutation=Mutation)
|
||||
|
||||
reporter = Reporter.objects.create(
|
||||
first_name="Bob", last_name="Roberts", a_choice=THIS
|
||||
)
|
||||
|
||||
result = schema.execute(
|
||||
""" mutation ReportMutation($reporterData: ReporterInputType!) {
|
||||
reportMutation(reporterData: $reporterData) {
|
||||
result {
|
||||
id,
|
||||
firstName,
|
||||
lastName,
|
||||
aChoice
|
||||
}
|
||||
}
|
||||
}
|
||||
""",
|
||||
variable_values={
|
||||
"reporterData": {
|
||||
"id": reporter.pk,
|
||||
"firstName": "Dave",
|
||||
"lastName": "Smith",
|
||||
"aChoice": THIS_ON_CLIENT_CONVERTED,
|
||||
}
|
||||
},
|
||||
)
|
||||
assert result.errors is None
|
||||
assert result.data["reportMutation"]["result"] == {
|
||||
"id": "1",
|
||||
"firstName": "Dave",
|
||||
"lastName": "Smith",
|
||||
"aChoice": THIS_ON_CLIENT_CONVERTED,
|
||||
}
|
||||
assert Reporter.objects.count() == 1
|
||||
reporter.refresh_from_db()
|
||||
assert reporter.first_name == "Dave"
|
||||
|
||||
|
||||
def reporter_enum_convert_mutation_result(
|
||||
ReporterInputType, choice_val_on_client=THIS_ON_CLIENT_CONVERTED
|
||||
):
|
||||
class ReporterMutation(graphene.Mutation):
|
||||
class Arguments:
|
||||
reporter = ReporterInputType(required=True)
|
||||
|
||||
result_str = graphene.String()
|
||||
result_int = graphene.Int()
|
||||
|
||||
def mutate(_root, _info, reporter):
|
||||
if isinstance(reporter.a_choice, int) or reporter.a_choice.isdigit():
|
||||
return ReporterMutation(result_int=reporter.a_choice, result_str=None)
|
||||
return ReporterMutation(result_int=None, result_str=reporter.a_choice)
|
||||
|
||||
class Mutation(graphene.ObjectType):
|
||||
report_mutation = ReporterMutation.Field()
|
||||
|
||||
schema = graphene.Schema(query=MockQuery, mutation=Mutation)
|
||||
|
||||
return schema.execute(
|
||||
""" mutation ReportMutation($reporter: ReporterInputType!) {
|
||||
reportMutation(reporter: $reporter) {
|
||||
resultStr,
|
||||
resultInt
|
||||
}
|
||||
}
|
||||
""",
|
||||
variable_values={"reporter": {"aChoice": choice_val_on_client}},
|
||||
)
|
||||
|
||||
|
||||
def test_enum_not_converted():
|
||||
class ReporterInputType(DjangoFormInputObjectType):
|
||||
class Meta:
|
||||
form_class = ReporterForm
|
||||
only_fields = ("a_choice",)
|
||||
|
||||
result = reporter_enum_convert_mutation_result(ReporterInputType)
|
||||
assert result.errors is None
|
||||
assert result.data["reportMutation"]["resultStr"] == THIS_ON_CLIENT_CONVERTED
|
||||
assert result.data["reportMutation"]["resultInt"] is None
|
||||
assert ReporterInputType._meta.fields["a_choice"].type is graphene.String
|
||||
|
||||
|
||||
def test_enum_is_converted_to_original():
|
||||
class ReporterInputType(DjangoFormInputObjectType):
|
||||
class Meta:
|
||||
form_class = ReporterForm
|
||||
object_type = ReporterType
|
||||
only_fields = ("a_choice",)
|
||||
|
||||
result = reporter_enum_convert_mutation_result(ReporterInputType)
|
||||
assert result.errors is None
|
||||
assert result.data["reportMutation"]["resultInt"] == THIS
|
||||
assert result.data["reportMutation"]["resultStr"] is None
|
||||
assert (
|
||||
ReporterInputType._meta.fields["a_choice"].type.__name__
|
||||
== "AChoiceEnumBackConvString"
|
||||
)
|
||||
|
||||
|
||||
def test_convert_choices_to_enum_is_false_and_field_type_as_in_model():
|
||||
class ReporterTypeNotConvertChoices(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Reporter
|
||||
convert_choices_to_enum = False
|
||||
fields = "__all__"
|
||||
|
||||
class ReporterInputType(DjangoFormInputObjectType):
|
||||
class Meta:
|
||||
form_class = ReporterForm
|
||||
object_type = ReporterTypeNotConvertChoices
|
||||
only_fields = ("a_choice",)
|
||||
|
||||
result = reporter_enum_convert_mutation_result(ReporterInputType, THIS)
|
||||
assert result.errors is None
|
||||
assert result.data["reportMutation"]["resultInt"] == THIS
|
||||
assert result.data["reportMutation"]["resultStr"] is None
|
||||
assert ReporterInputType._meta.fields["a_choice"].type is graphene.Int
|
||||
|
||||
|
||||
def enum_convert_mutation_result_film(FilmInputType):
|
||||
class FilmMutation(graphene.Mutation):
|
||||
class Arguments:
|
||||
film = FilmInputType(required=True)
|
||||
|
||||
result = graphene.String()
|
||||
|
||||
def mutate(_root, _info, film):
|
||||
return FilmMutation(result=film.genre)
|
||||
|
||||
class Mutation(graphene.ObjectType):
|
||||
film_mutation = FilmMutation.Field()
|
||||
|
||||
schema = graphene.Schema(query=MockQuery, mutation=Mutation)
|
||||
|
||||
return schema.execute(
|
||||
""" mutation FilmMutation($film: FilmInputType!) {
|
||||
filmMutation(film: $film) {
|
||||
result
|
||||
}
|
||||
}
|
||||
""",
|
||||
variable_values={"film": {"genre": DOCUMENTARY_ON_CLIENT_CONVERTED}},
|
||||
)
|
||||
|
||||
|
||||
def test_enum_not_converted_required_non_number():
|
||||
class FilmInputType(DjangoFormInputObjectType):
|
||||
class Meta:
|
||||
form_class = FilmForm
|
||||
only_fields = ("genre",)
|
||||
|
||||
result = enum_convert_mutation_result_film(FilmInputType)
|
||||
assert result.errors is None
|
||||
assert result.data["filmMutation"]["result"] == DOCUMENTARY_ON_CLIENT_CONVERTED
|
||||
|
||||
|
||||
def test_enum_is_converted_to_original_required_non_number():
|
||||
class FilmType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Film
|
||||
fields = "__all__"
|
||||
|
||||
class FilmInputType(DjangoFormInputObjectType):
|
||||
class Meta:
|
||||
form_class = FilmForm
|
||||
object_type = FilmType
|
||||
only_fields = ("genre",)
|
||||
|
||||
result = enum_convert_mutation_result_film(FilmInputType)
|
||||
assert result.errors is None
|
||||
assert result.data["filmMutation"]["result"] == DOCUMENTARY
|
|
@ -1,4 +1,3 @@
|
|||
import pytest
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from pytest import raises
|
||||
|
@ -280,7 +279,7 @@ def test_model_form_mutation_mutate_invalid_form():
|
|||
result = PetMutation.mutate_and_get_payload(None, None)
|
||||
|
||||
# A pet was not created
|
||||
Pet.objects.count() == 0
|
||||
assert Pet.objects.count() == 0
|
||||
|
||||
fields_w_error = [e.field for e in result.errors]
|
||||
assert len(result.errors) == 2
|
||||
|
|
|
@ -1 +1,117 @@
|
|||
from ..types import ErrorType # noqa Import ErrorType for backwards compatability
|
||||
import graphene
|
||||
from graphene import ID
|
||||
from graphene.types.inputobjecttype import InputObjectType
|
||||
from graphene.utils.str_converters import to_camel_case
|
||||
|
||||
from ..converter import BlankValueField
|
||||
from ..types import ErrorType # noqa Import ErrorType for backwards compatibility
|
||||
from .mutation import fields_for_form
|
||||
|
||||
|
||||
class DjangoFormInputObjectType(InputObjectType):
|
||||
@classmethod
|
||||
def __init_subclass_with_meta__(
|
||||
cls,
|
||||
container=None,
|
||||
_meta=None,
|
||||
only_fields=(),
|
||||
exclude_fields=(),
|
||||
form_class=None,
|
||||
object_type=None,
|
||||
add_id_field_name=None,
|
||||
add_id_field_type=None,
|
||||
**options,
|
||||
):
|
||||
"""Retrieve fields from django form (Meta.form_class). Received
|
||||
fields are set to cls (they will be converted to input fields
|
||||
by InputObjectType). Type of fields with choices (converted
|
||||
to enum) is set to custom scalar type (using Meta.object_type)
|
||||
to dynamically convert enum values back.
|
||||
|
||||
class MyDjangoFormInput(DjangoFormInputObjectType):
|
||||
# any other fields can be placed here and other inputobjectforms as well
|
||||
|
||||
class Meta:
|
||||
form_class = MyDjangoModelForm
|
||||
object_type = MyModelType
|
||||
|
||||
class SomeMutation(graphene.Mutation):
|
||||
class Arguments:
|
||||
data = MyDjangoFormInput(required=True)
|
||||
|
||||
@staticmethod
|
||||
def mutate(_root, _info, data):
|
||||
form_inst = MyDjangoModelForm(data=data)
|
||||
if form_inst.is_valid():
|
||||
django_model_instance = form_inst.save(commit=False)
|
||||
# ... etc ...
|
||||
"""
|
||||
|
||||
if not form_class:
|
||||
raise Exception("form_class is required for DjangoFormInputObjectType")
|
||||
|
||||
form = form_class()
|
||||
form_fields = fields_for_form(form, only_fields, exclude_fields)
|
||||
|
||||
for name, field in form_fields.items():
|
||||
if (
|
||||
object_type
|
||||
and name in object_type._meta.fields
|
||||
and isinstance(object_type._meta.fields[name], BlankValueField)
|
||||
):
|
||||
# Field type BlankValueField here means that field
|
||||
# with choices have been converted to enum
|
||||
# (BlankValueField is using only for that task ?)
|
||||
setattr(cls, name, cls.get_enum_cnv_cls_instance(name, object_type))
|
||||
elif (
|
||||
object_type
|
||||
and name in object_type._meta.fields
|
||||
and object_type._meta.convert_choices_to_enum is False
|
||||
and form.fields[name].__class__.__name__ == "TypedChoiceField"
|
||||
):
|
||||
# FIXME
|
||||
# in case if convert_choices_to_enum is False
|
||||
# form field class is converted to String but original
|
||||
# model field type is needed here... (.converter.py bug?)
|
||||
# This is temp workaround to get field type from ObjectType field
|
||||
# TEST: test_enum_not_converted_and_field_type_as_in_model
|
||||
setattr(cls, name, object_type._meta.fields[name].type())
|
||||
else:
|
||||
# set input field according to django form field
|
||||
setattr(cls, name, field)
|
||||
|
||||
# explicitly adding id field (absent in django form fields)
|
||||
# with name and type from Meta or 'id' with graphene.ID by default
|
||||
if add_id_field_name:
|
||||
setattr(cls, add_id_field_name, add_id_field_type or ID(required=False))
|
||||
elif "id" not in exclude_fields:
|
||||
cls.id = ID(required=False)
|
||||
|
||||
super().__init_subclass_with_meta__(container=container, _meta=_meta, **options)
|
||||
|
||||
@staticmethod
|
||||
def get_enum_cnv_cls_instance(field_name, object_type):
|
||||
"""Saves args in context to convert enum values in
|
||||
Dynamically created Scalar derived class
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def parse_value(value):
|
||||
# field_name & object_type have been saved in context (closure)
|
||||
field = object_type._meta.fields[field_name]
|
||||
if isinstance(field.type, graphene.NonNull):
|
||||
val_before_convert = field.type._of_type[value].value
|
||||
else:
|
||||
val_before_convert = field.type[value].value
|
||||
return graphene.String.parse_value(val_before_convert)
|
||||
|
||||
cls_doc = "String scalar to convert choice value back from enum to original"
|
||||
scalar_type = type(
|
||||
(
|
||||
f"{field_name[0].upper()}{to_camel_case(field_name[1:])}"
|
||||
"EnumBackConvString"
|
||||
),
|
||||
(graphene.String,),
|
||||
{"parse_value": parse_value, "__doc__": cls_doc},
|
||||
)
|
||||
return scalar_type()
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import os
|
||||
import functools
|
||||
import importlib
|
||||
import json
|
||||
import functools
|
||||
import os
|
||||
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django.utils import autoreload
|
||||
|
||||
from graphql import print_schema
|
||||
|
||||
from graphene_django.settings import graphene_settings
|
||||
|
||||
|
||||
|
@ -83,7 +83,7 @@ class Command(CommandArguments):
|
|||
def handle(self, *args, **options):
|
||||
options_schema = options.get("schema")
|
||||
|
||||
if options_schema and type(options_schema) is str:
|
||||
if options_schema and isinstance(options_schema, str):
|
||||
module_str, schema_name = options_schema.rsplit(".", 1)
|
||||
mod = importlib.import_module(module_str)
|
||||
schema = getattr(mod, schema_name)
|
||||
|
|
|
@ -8,9 +8,7 @@ class Registry:
|
|||
|
||||
assert issubclass(
|
||||
cls, DjangoObjectType
|
||||
), 'Only DjangoObjectTypes can be registered, received "{}"'.format(
|
||||
cls.__name__
|
||||
)
|
||||
), f'Only DjangoObjectTypes can be registered, received "{cls.__name__}"'
|
||||
assert cls._meta.registry == self, "Registry for a Model have to match."
|
||||
# assert self.get_type_for_model(cls._meta.model) == cls, (
|
||||
# 'Multiple DjangoObjectTypes registered for "{}"'.format(cls._meta.model)
|
||||
|
|
|
@ -14,3 +14,14 @@ class MyFakeModelWithPassword(models.Model):
|
|||
class MyFakeModelWithDate(models.Model):
|
||||
cool_name = models.CharField(max_length=50)
|
||||
last_edited = models.DateField()
|
||||
|
||||
|
||||
class MyFakeModelWithChoiceField(models.Model):
|
||||
class ChoiceType(models.Choices):
|
||||
ASDF = "asdf"
|
||||
HI = "hi"
|
||||
|
||||
choice_type = models.CharField(
|
||||
max_length=4,
|
||||
default=ChoiceType.HI.name,
|
||||
)
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
from collections import OrderedDict
|
||||
from enum import Enum
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework import serializers
|
||||
|
@ -18,6 +19,7 @@ class SerializerMutationOptions(MutationOptions):
|
|||
model_class = None
|
||||
model_operations = ["create", "update"]
|
||||
serializer_class = None
|
||||
optional_fields = ()
|
||||
|
||||
|
||||
def fields_for_serializer(
|
||||
|
@ -27,6 +29,7 @@ def fields_for_serializer(
|
|||
is_input=False,
|
||||
convert_choices_to_enum=True,
|
||||
lookup_field=None,
|
||||
optional_fields=(),
|
||||
):
|
||||
fields = OrderedDict()
|
||||
for name, field in serializer.fields.items():
|
||||
|
@ -39,14 +42,21 @@ def fields_for_serializer(
|
|||
field.read_only
|
||||
and is_input
|
||||
and lookup_field != name, # don't show read_only fields in Input
|
||||
isinstance(
|
||||
field, serializers.HiddenField
|
||||
), # don't show hidden fields in Input
|
||||
]
|
||||
)
|
||||
|
||||
if is_not_in_only or is_excluded:
|
||||
continue
|
||||
is_optional = name in optional_fields or "__all__" in optional_fields
|
||||
|
||||
fields[name] = convert_serializer_field(
|
||||
field, is_input=is_input, convert_choices_to_enum=convert_choices_to_enum
|
||||
field,
|
||||
is_input=is_input,
|
||||
convert_choices_to_enum=convert_choices_to_enum,
|
||||
force_optional=is_optional,
|
||||
)
|
||||
return fields
|
||||
|
||||
|
@ -70,7 +80,8 @@ class SerializerMutation(ClientIDMutation):
|
|||
exclude_fields=(),
|
||||
convert_choices_to_enum=True,
|
||||
_meta=None,
|
||||
**options
|
||||
optional_fields=(),
|
||||
**options,
|
||||
):
|
||||
if not serializer_class:
|
||||
raise Exception("serializer_class is required for the SerializerMutation")
|
||||
|
@ -94,6 +105,7 @@ class SerializerMutation(ClientIDMutation):
|
|||
is_input=True,
|
||||
convert_choices_to_enum=convert_choices_to_enum,
|
||||
lookup_field=lookup_field,
|
||||
optional_fields=optional_fields,
|
||||
)
|
||||
output_fields = fields_for_serializer(
|
||||
serializer,
|
||||
|
@ -121,8 +133,10 @@ class SerializerMutation(ClientIDMutation):
|
|||
def get_serializer_kwargs(cls, root, info, **input):
|
||||
lookup_field = cls._meta.lookup_field
|
||||
model_class = cls._meta.model_class
|
||||
|
||||
if model_class:
|
||||
for input_dict_key, maybe_enum in input.items():
|
||||
if isinstance(maybe_enum, Enum):
|
||||
input[input_dict_key] = maybe_enum.value
|
||||
if "update" in cls._meta.model_operations and lookup_field in input:
|
||||
instance = get_object_or_404(
|
||||
model_class, **{lookup_field: input[lookup_field]}
|
||||
|
|
|
@ -5,20 +5,22 @@ from rest_framework import serializers
|
|||
|
||||
import graphene
|
||||
|
||||
from ..registry import get_global_registry
|
||||
from ..converter import convert_choices_to_named_enum_with_descriptions
|
||||
from ..registry import get_global_registry
|
||||
from .types import DictType
|
||||
|
||||
|
||||
@singledispatch
|
||||
def get_graphene_type_from_serializer_field(field):
|
||||
raise ImproperlyConfigured(
|
||||
"Don't know how to convert the serializer field %s (%s) "
|
||||
"to Graphene type" % (field, field.__class__)
|
||||
f"Don't know how to convert the serializer field {field} ({field.__class__}) "
|
||||
"to Graphene type"
|
||||
)
|
||||
|
||||
|
||||
def convert_serializer_field(field, is_input=True, convert_choices_to_enum=True):
|
||||
def convert_serializer_field(
|
||||
field, is_input=True, convert_choices_to_enum=True, force_optional=False
|
||||
):
|
||||
"""
|
||||
Converts a django rest frameworks field to a graphql field
|
||||
and marks the field as required if we are creating an input type
|
||||
|
@ -31,7 +33,10 @@ def convert_serializer_field(field, is_input=True, convert_choices_to_enum=True)
|
|||
graphql_type = get_graphene_type_from_serializer_field(field)
|
||||
|
||||
args = []
|
||||
kwargs = {"description": field.help_text, "required": is_input and field.required}
|
||||
kwargs = {
|
||||
"description": field.help_text,
|
||||
"required": is_input and field.required and not force_optional,
|
||||
}
|
||||
|
||||
# if it is a tuple or a list it means that we are returning
|
||||
# the graphql type and the child type
|
||||
|
|
|
@ -1,11 +1,11 @@
|
|||
import copy
|
||||
|
||||
import graphene
|
||||
from django.db import models
|
||||
from graphene import InputObjectType
|
||||
from pytest import raises
|
||||
from rest_framework import serializers
|
||||
|
||||
import graphene
|
||||
|
||||
from ..serializer_converter import convert_serializer_field
|
||||
from ..types import DictType
|
||||
|
||||
|
|
|
@ -3,11 +3,16 @@ import datetime
|
|||
from pytest import raises
|
||||
from rest_framework import serializers
|
||||
|
||||
from graphene import Field, ResolveInfo
|
||||
from graphene import Field, ResolveInfo, String
|
||||
from graphene.types.inputobjecttype import InputObjectType
|
||||
|
||||
from ...types import DjangoObjectType
|
||||
from ..models import MyFakeModel, MyFakeModelWithDate, MyFakeModelWithPassword
|
||||
from ..models import (
|
||||
MyFakeModel,
|
||||
MyFakeModelWithChoiceField,
|
||||
MyFakeModelWithDate,
|
||||
MyFakeModelWithPassword,
|
||||
)
|
||||
from ..mutation import SerializerMutation
|
||||
|
||||
|
||||
|
@ -100,6 +105,16 @@ def test_exclude_fields():
|
|||
assert "created" not in MyMutation.Input._meta.fields
|
||||
|
||||
|
||||
def test_model_serializer_optional_fields():
|
||||
class MyMutation(SerializerMutation):
|
||||
class Meta:
|
||||
serializer_class = MyModelSerializer
|
||||
optional_fields = ("cool_name",)
|
||||
|
||||
assert "cool_name" in MyMutation.Input._meta.fields
|
||||
assert MyMutation.Input._meta.fields["cool_name"].type == String
|
||||
|
||||
|
||||
def test_write_only_field():
|
||||
class WriteOnlyFieldModelSerializer(serializers.ModelSerializer):
|
||||
password = serializers.CharField(write_only=True)
|
||||
|
@ -164,6 +179,21 @@ def test_read_only_fields():
|
|||
), "'cool_name' is read_only field and shouldn't be on arguments"
|
||||
|
||||
|
||||
def test_hidden_fields():
|
||||
class SerializerWithHiddenField(serializers.Serializer):
|
||||
cool_name = serializers.CharField()
|
||||
user = serializers.HiddenField(default=serializers.CurrentUserDefault())
|
||||
|
||||
class MyMutation(SerializerMutation):
|
||||
class Meta:
|
||||
serializer_class = SerializerWithHiddenField
|
||||
|
||||
assert "cool_name" in MyMutation.Input._meta.fields
|
||||
assert (
|
||||
"user" not in MyMutation.Input._meta.fields
|
||||
), "'user' is hidden field and shouldn't be on arguments"
|
||||
|
||||
|
||||
def test_nested_model():
|
||||
class MyFakeModelGrapheneType(DjangoObjectType):
|
||||
class Meta:
|
||||
|
@ -230,7 +260,7 @@ def test_model_invalid_update_mutate_and_get_payload_success():
|
|||
model_operations = ["update"]
|
||||
|
||||
with raises(Exception) as exc:
|
||||
result = InvalidModelMutation.mutate_and_get_payload(
|
||||
InvalidModelMutation.mutate_and_get_payload(
|
||||
None, mock_info(), **{"cool_name": "Narf"}
|
||||
)
|
||||
|
||||
|
@ -245,7 +275,7 @@ def test_perform_mutate_success():
|
|||
result = MyMethodMutation.mutate_and_get_payload(
|
||||
None,
|
||||
mock_info(),
|
||||
**{"cool_name": "Narf", "last_edited": datetime.date(2020, 1, 4)}
|
||||
**{"cool_name": "Narf", "last_edited": datetime.date(2020, 1, 4)},
|
||||
)
|
||||
|
||||
assert result.errors is None
|
||||
|
@ -253,6 +283,39 @@ def test_perform_mutate_success():
|
|||
assert result.days_since_last_edit == 4
|
||||
|
||||
|
||||
def test_perform_mutate_success_with_enum_choice_field():
|
||||
class ListViewChoiceFieldSerializer(serializers.ModelSerializer):
|
||||
choice_type = serializers.ChoiceField(
|
||||
choices=[(x.name, x.value) for x in MyFakeModelWithChoiceField.ChoiceType],
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = MyFakeModelWithChoiceField
|
||||
fields = "__all__"
|
||||
|
||||
class SomeCreateSerializerMutation(SerializerMutation):
|
||||
class Meta:
|
||||
serializer_class = ListViewChoiceFieldSerializer
|
||||
|
||||
choice_type = {
|
||||
"choice_type": SomeCreateSerializerMutation.Input.choice_type.type.get("ASDF")
|
||||
}
|
||||
name = MyFakeModelWithChoiceField.ChoiceType.ASDF.name
|
||||
result = SomeCreateSerializerMutation.mutate_and_get_payload(
|
||||
None, mock_info(), **choice_type
|
||||
)
|
||||
assert result.errors is None
|
||||
assert result.choice_type == name
|
||||
kwargs = SomeCreateSerializerMutation.get_serializer_kwargs(
|
||||
None, mock_info(), **choice_type
|
||||
)
|
||||
assert kwargs["data"]["choice_type"] == name
|
||||
assert 1 == MyFakeModelWithChoiceField.objects.count()
|
||||
item = MyFakeModelWithChoiceField.objects.first()
|
||||
assert item.choice_type == name
|
||||
|
||||
|
||||
def test_mutate_and_get_payload_error():
|
||||
class MyMutation(SerializerMutation):
|
||||
class Meta:
|
||||
|
|
|
@ -12,11 +12,10 @@ Graphene settings, checking for user settings first, then falling
|
|||
back to the defaults.
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.test.signals import setting_changed
|
||||
|
||||
import importlib # Available in Python 3.1+
|
||||
|
||||
from django.conf import settings
|
||||
from django.test.signals import setting_changed
|
||||
|
||||
# Copied shamelessly from Django REST Framework
|
||||
|
||||
|
@ -31,6 +30,8 @@ DEFAULTS = {
|
|||
# Max items returned in ConnectionFields / FilterConnectionFields
|
||||
"RELAY_CONNECTION_MAX_LIMIT": 100,
|
||||
"CAMELCASE_ERRORS": True,
|
||||
# Automatically convert Choice fields of Django into Enum fields
|
||||
"DJANGO_CHOICE_FIELD_ENUM_CONVERT": True,
|
||||
# Set to True to enable v2 naming convention for choice field Enum's
|
||||
"DJANGO_CHOICE_FIELD_ENUM_V2_NAMING": False,
|
||||
"DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME": None,
|
||||
|
@ -41,8 +42,10 @@ DEFAULTS = {
|
|||
# https://github.com/graphql/graphiql/tree/main/packages/graphiql#options
|
||||
"GRAPHIQL_HEADER_EDITOR_ENABLED": True,
|
||||
"GRAPHIQL_SHOULD_PERSIST_HEADERS": False,
|
||||
"GRAPHIQL_INPUT_VALUE_DEPRECATION": False,
|
||||
"ATOMIC_MUTATIONS": False,
|
||||
"TESTING_ENDPOINT": "/graphql",
|
||||
"MAX_VALIDATION_ERRORS": None,
|
||||
}
|
||||
|
||||
if settings.DEBUG:
|
||||
|
|
|
@ -122,6 +122,7 @@
|
|||
onEditOperationName: onEditOperationName,
|
||||
isHeadersEditorEnabled: GRAPHENE_SETTINGS.graphiqlHeaderEditorEnabled,
|
||||
shouldPersistHeaders: GRAPHENE_SETTINGS.graphiqlShouldPersistHeaders,
|
||||
inputValueDeprecation: GRAPHENE_SETTINGS.graphiqlInputValueDeprecation,
|
||||
query: query,
|
||||
};
|
||||
if (parameters.variables) {
|
||||
|
|
|
@ -21,6 +21,10 @@ add "&raw" to the end of the URL within a browser.
|
|||
integrity="{{graphiql_css_sri}}"
|
||||
rel="stylesheet"
|
||||
crossorigin="anonymous" />
|
||||
<link href="https://cdn.jsdelivr.net/npm/@graphiql/plugin-explorer@{{graphiql_plugin_explorer_version}}/dist/style.css"
|
||||
integrity="{{graphiql_plugin_explorer_css_sri}}"
|
||||
rel="stylesheet"
|
||||
crossorigin="anonymous" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/whatwg-fetch@{{whatwg_fetch_version}}/dist/fetch.umd.js"
|
||||
integrity="{{whatwg_fetch_sri}}"
|
||||
crossorigin="anonymous"></script>
|
||||
|
@ -50,6 +54,7 @@ add "&raw" to the end of the URL within a browser.
|
|||
{% endif %}
|
||||
graphiqlHeaderEditorEnabled: {{ graphiql_header_editor_enabled|yesno:"true,false" }},
|
||||
graphiqlShouldPersistHeaders: {{ graphiql_should_persist_headers|yesno:"true,false" }},
|
||||
graphiqlInputValueDeprecation: {{ graphiql_input_value_deprecation|yesno:"true,false" }},
|
||||
};
|
||||
</script>
|
||||
<script src="{% static 'graphene_django/graphiql.js' %}"></script>
|
||||
|
|
|
@ -1,21 +1,14 @@
|
|||
# https://github.com/graphql-python/graphene-django/issues/520
|
||||
|
||||
import datetime
|
||||
|
||||
from django import forms
|
||||
from rest_framework import serializers
|
||||
|
||||
import graphene
|
||||
|
||||
from graphene import Field, ResolveInfo
|
||||
from graphene.types.inputobjecttype import InputObjectType
|
||||
from pytest import raises
|
||||
from pytest import mark
|
||||
from rest_framework import serializers
|
||||
|
||||
from ...types import DjangoObjectType
|
||||
from ...forms.mutation import DjangoFormMutation
|
||||
from ...rest_framework.models import MyFakeModel
|
||||
from ...rest_framework.mutation import SerializerMutation
|
||||
from ...forms.mutation import DjangoFormMutation
|
||||
|
||||
|
||||
class MyModelSerializer(serializers.ModelSerializer):
|
||||
|
|
|
@ -6,6 +6,9 @@ CHOICES = ((1, "this"), (2, _("that")))
|
|||
|
||||
class Person(models.Model):
|
||||
name = models.CharField(max_length=30)
|
||||
parent = models.ForeignKey(
|
||||
"self", on_delete=models.CASCADE, null=True, blank=True, related_name="children"
|
||||
)
|
||||
|
||||
|
||||
class Pet(models.Model):
|
||||
|
@ -19,7 +22,11 @@ class Pet(models.Model):
|
|||
class FilmDetails(models.Model):
|
||||
location = models.CharField(max_length=30)
|
||||
film = models.OneToOneField(
|
||||
"Film", on_delete=models.CASCADE, related_name="details"
|
||||
"Film",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="details",
|
||||
null=True,
|
||||
blank=True,
|
||||
)
|
||||
|
||||
|
||||
|
@ -43,9 +50,10 @@ class Reporter(models.Model):
|
|||
last_name = models.CharField(max_length=30)
|
||||
email = models.EmailField()
|
||||
pets = models.ManyToManyField("self")
|
||||
a_choice = models.CharField(max_length=30, choices=CHOICES, blank=True)
|
||||
a_choice = models.IntegerField(choices=CHOICES, null=True, blank=True)
|
||||
objects = models.Manager()
|
||||
doe_objects = DoeReporterManager()
|
||||
fans = models.ManyToManyField(Person)
|
||||
|
||||
reporter_type = models.IntegerField(
|
||||
"Reporter Type",
|
||||
|
@ -90,6 +98,16 @@ class CNNReporter(Reporter):
|
|||
objects = CNNReporterManager()
|
||||
|
||||
|
||||
class APNewsReporter(Reporter):
|
||||
"""
|
||||
This class only inherits from Reporter for testing multi table inheritance
|
||||
similar to what you'd see in django-polymorphic
|
||||
"""
|
||||
|
||||
alias = models.CharField(max_length=30)
|
||||
objects = models.Manager()
|
||||
|
||||
|
||||
class Article(models.Model):
|
||||
headline = models.CharField(max_length=100)
|
||||
pub_date = models.DateField(auto_now_add=True)
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
from graphene import Field
|
||||
|
||||
from graphene_django.forms.mutation import DjangoFormMutation, DjangoModelFormMutation
|
||||
|
||||
from .forms import PetForm
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
from io import StringIO
|
||||
from textwrap import dedent
|
||||
from unittest.mock import mock_open, patch
|
||||
|
||||
from django.core import management
|
||||
from io import StringIO
|
||||
from unittest.mock import mock_open, patch
|
||||
|
||||
from graphene import ObjectType, Schema, String
|
||||
|
||||
|
@ -46,7 +46,7 @@ def test_generate_graphql_file_on_call_graphql_schema():
|
|||
open_mock.assert_called_once()
|
||||
|
||||
handle = open_mock()
|
||||
assert handle.write.called_once()
|
||||
handle.write.assert_called_once()
|
||||
|
||||
schema_output = handle.write.call_args[0][0]
|
||||
assert schema_output == dedent(
|
||||
|
|
|
@ -15,8 +15,6 @@ from graphene.types.scalars import BigInt
|
|||
from ..compat import (
|
||||
ArrayField,
|
||||
HStoreField,
|
||||
JSONField,
|
||||
PGJSONField,
|
||||
MissingType,
|
||||
RangeField,
|
||||
)
|
||||
|
@ -33,10 +31,10 @@ from .models import Article, Film, FilmDetails, Reporter
|
|||
|
||||
|
||||
def assert_conversion(django_field, graphene_field, *args, **kwargs):
|
||||
_kwargs = kwargs.copy()
|
||||
_kwargs = {**kwargs, "help_text": "Custom Help Text"}
|
||||
if "null" not in kwargs:
|
||||
_kwargs["null"] = True
|
||||
field = django_field(help_text="Custom Help Text", *args, **_kwargs)
|
||||
field = django_field(*args, **_kwargs)
|
||||
graphene_type = convert_django_field(field)
|
||||
assert isinstance(graphene_type, graphene_field)
|
||||
field = graphene_type.Field()
|
||||
|
@ -182,6 +180,26 @@ def test_field_with_choices_convert_enum():
|
|||
assert graphene_type._meta.enum.__members__["EN"].description == "English"
|
||||
|
||||
|
||||
def test_field_with_callable_choices_convert_enum():
|
||||
def get_choices():
|
||||
return ("es", "Spanish"), ("en", "English")
|
||||
|
||||
field = models.CharField(help_text="Language", choices=get_choices)
|
||||
|
||||
class TranslatedModel(models.Model):
|
||||
language = field
|
||||
|
||||
class Meta:
|
||||
app_label = "test"
|
||||
|
||||
graphene_type = convert_django_field_with_choices(field).type.of_type
|
||||
assert graphene_type._meta.name == "TestTranslatedModelLanguageChoices"
|
||||
assert graphene_type._meta.enum.__members__["ES"].value == "es"
|
||||
assert graphene_type._meta.enum.__members__["ES"].description == "Spanish"
|
||||
assert graphene_type._meta.enum.__members__["EN"].value == "en"
|
||||
assert graphene_type._meta.enum.__members__["EN"].description == "English"
|
||||
|
||||
|
||||
def test_field_with_grouped_choices():
|
||||
field = models.CharField(
|
||||
help_text="Language",
|
||||
|
@ -372,16 +390,6 @@ def test_should_postgres_hstore_convert_string():
|
|||
assert_conversion(HStoreField, JSONString)
|
||||
|
||||
|
||||
@pytest.mark.skipif(PGJSONField is MissingType, reason="PGJSONField should exist")
|
||||
def test_should_postgres_json_convert_string():
|
||||
assert_conversion(PGJSONField, JSONString)
|
||||
|
||||
|
||||
@pytest.mark.skipif(JSONField is MissingType, reason="JSONField should exist")
|
||||
def test_should_json_convert_string():
|
||||
assert_conversion(JSONField, JSONString)
|
||||
|
||||
|
||||
@pytest.mark.skipif(RangeField is MissingType, reason="RangeField should exist")
|
||||
def test_should_postgres_range_convert_list():
|
||||
from django.contrib.postgres.fields import IntegerRangeField
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import datetime
|
||||
import re
|
||||
from django.db.models import Count, Prefetch
|
||||
|
||||
import pytest
|
||||
from django.db.models import Count, Prefetch
|
||||
|
||||
from graphene import List, NonNull, ObjectType, Schema, String
|
||||
|
||||
|
@ -12,17 +12,23 @@ from .models import (
|
|||
Article as ArticleModel,
|
||||
Film as FilmModel,
|
||||
FilmDetails as FilmDetailsModel,
|
||||
Person as PersonModel,
|
||||
Reporter as ReporterModel,
|
||||
)
|
||||
|
||||
|
||||
class TestDjangoListField:
|
||||
def test_only_django_object_types(self):
|
||||
class TestType(ObjectType):
|
||||
foo = String()
|
||||
class Query(ObjectType):
|
||||
something = DjangoListField(String)
|
||||
|
||||
with pytest.raises(AssertionError):
|
||||
list_field = DjangoListField(TestType)
|
||||
with pytest.raises(TypeError) as excinfo:
|
||||
Schema(query=Query)
|
||||
|
||||
assert (
|
||||
"Query fields cannot be resolved. DjangoListField only accepts DjangoObjectType types as underlying type"
|
||||
in str(excinfo.value)
|
||||
)
|
||||
|
||||
def test_only_import_paths(self):
|
||||
list_field = DjangoListField("graphene_django.tests.schema.Human")
|
||||
|
@ -262,6 +268,69 @@ class TestDjangoListField:
|
|||
]
|
||||
}
|
||||
|
||||
def test_same_type_nested_list_field(self):
|
||||
class Person(DjangoObjectType):
|
||||
class Meta:
|
||||
model = PersonModel
|
||||
fields = ("name", "parent")
|
||||
|
||||
children = DjangoListField(lambda: Person)
|
||||
|
||||
class Query(ObjectType):
|
||||
persons = DjangoListField(Person)
|
||||
|
||||
schema = Schema(query=Query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
persons {
|
||||
name
|
||||
children {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
p1 = PersonModel.objects.create(name="Tara")
|
||||
PersonModel.objects.create(name="Debra")
|
||||
|
||||
PersonModel.objects.create(
|
||||
name="Toto",
|
||||
parent=p1,
|
||||
)
|
||||
PersonModel.objects.create(
|
||||
name="Tata",
|
||||
parent=p1,
|
||||
)
|
||||
|
||||
result = schema.execute(query)
|
||||
|
||||
assert not result.errors
|
||||
assert result.data == {
|
||||
"persons": [
|
||||
{
|
||||
"name": "Tara",
|
||||
"children": [
|
||||
{"name": "Toto"},
|
||||
{"name": "Tata"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "Debra",
|
||||
"children": [],
|
||||
},
|
||||
{
|
||||
"name": "Toto",
|
||||
"children": [],
|
||||
},
|
||||
{
|
||||
"name": "Tata",
|
||||
"children": [],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
def test_get_queryset_filter(self):
|
||||
class Reporter(DjangoObjectType):
|
||||
class Meta:
|
||||
|
|
|
@ -3,7 +3,6 @@ from pytest import raises
|
|||
|
||||
from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField
|
||||
|
||||
|
||||
# 'TXlUeXBlOmFiYw==' -> 'MyType', 'abc'
|
||||
|
||||
|
||||
|
|
|
@ -1,14 +1,11 @@
|
|||
import pytest
|
||||
from graphql_relay import to_global_id
|
||||
|
||||
import graphene
|
||||
from graphene.relay import Node
|
||||
|
||||
from graphql_relay import to_global_id
|
||||
|
||||
from ..fields import DjangoConnectionField
|
||||
from ..types import DjangoObjectType
|
||||
|
||||
from .models import Article, Reporter
|
||||
from .models import Article, Film, FilmDetails, Reporter
|
||||
|
||||
|
||||
class TestShouldCallGetQuerySetOnForeignKey:
|
||||
|
@ -127,6 +124,69 @@ class TestShouldCallGetQuerySetOnForeignKey:
|
|||
assert not result.errors
|
||||
assert result.data == {"reporter": {"firstName": "Jane"}}
|
||||
|
||||
def test_get_queryset_called_on_foreignkey(self):
|
||||
# If a user tries to access a reporter through an article they should get our authorization error
|
||||
query = """
|
||||
query getArticle($id: ID!) {
|
||||
article(id: $id) {
|
||||
headline
|
||||
reporter {
|
||||
firstName
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = self.schema.execute(query, variables={"id": self.articles[0].id})
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0].message == "Not authorized to access reporters."
|
||||
|
||||
# An admin user should be able to get reporters through an article
|
||||
query = """
|
||||
query getArticle($id: ID!) {
|
||||
article(id: $id) {
|
||||
headline
|
||||
reporter {
|
||||
firstName
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = self.schema.execute(
|
||||
query,
|
||||
variables={"id": self.articles[0].id},
|
||||
context_value={"admin": True},
|
||||
)
|
||||
assert not result.errors
|
||||
assert result.data["article"] == {
|
||||
"headline": "A fantastic article",
|
||||
"reporter": {"firstName": "Jane"},
|
||||
}
|
||||
|
||||
# An admin user should not be able to access draft article through a reporter
|
||||
query = """
|
||||
query getReporter($id: ID!) {
|
||||
reporter(id: $id) {
|
||||
firstName
|
||||
articles {
|
||||
headline
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = self.schema.execute(
|
||||
query,
|
||||
variables={"id": self.reporter.id},
|
||||
context_value={"admin": True},
|
||||
)
|
||||
assert not result.errors
|
||||
assert result.data["reporter"] == {
|
||||
"firstName": "Jane",
|
||||
"articles": [{"headline": "A fantastic article"}],
|
||||
}
|
||||
|
||||
|
||||
class TestShouldCallGetQuerySetOnForeignKeyNode:
|
||||
"""
|
||||
|
@ -233,3 +293,272 @@ class TestShouldCallGetQuerySetOnForeignKeyNode:
|
|||
)
|
||||
assert not result.errors
|
||||
assert result.data == {"reporter": {"firstName": "Jane"}}
|
||||
|
||||
def test_get_queryset_called_on_foreignkey(self):
|
||||
# If a user tries to access a reporter through an article they should get our authorization error
|
||||
query = """
|
||||
query getArticle($id: ID!) {
|
||||
article(id: $id) {
|
||||
headline
|
||||
reporter {
|
||||
firstName
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = self.schema.execute(
|
||||
query, variables={"id": to_global_id("ArticleType", self.articles[0].id)}
|
||||
)
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0].message == "Not authorized to access reporters."
|
||||
|
||||
# An admin user should be able to get reporters through an article
|
||||
query = """
|
||||
query getArticle($id: ID!) {
|
||||
article(id: $id) {
|
||||
headline
|
||||
reporter {
|
||||
firstName
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = self.schema.execute(
|
||||
query,
|
||||
variables={"id": to_global_id("ArticleType", self.articles[0].id)},
|
||||
context_value={"admin": True},
|
||||
)
|
||||
assert not result.errors
|
||||
assert result.data["article"] == {
|
||||
"headline": "A fantastic article",
|
||||
"reporter": {"firstName": "Jane"},
|
||||
}
|
||||
|
||||
# An admin user should not be able to access draft article through a reporter
|
||||
query = """
|
||||
query getReporter($id: ID!) {
|
||||
reporter(id: $id) {
|
||||
firstName
|
||||
articles {
|
||||
edges {
|
||||
node {
|
||||
headline
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = self.schema.execute(
|
||||
query,
|
||||
variables={"id": to_global_id("ReporterType", self.reporter.id)},
|
||||
context_value={"admin": True},
|
||||
)
|
||||
assert not result.errors
|
||||
assert result.data["reporter"] == {
|
||||
"firstName": "Jane",
|
||||
"articles": {"edges": [{"node": {"headline": "A fantastic article"}}]},
|
||||
}
|
||||
|
||||
|
||||
class TestShouldCallGetQuerySetOnOneToOne:
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_schema(self):
|
||||
class FilmDetailsType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = FilmDetails
|
||||
|
||||
@classmethod
|
||||
def get_queryset(cls, queryset, info):
|
||||
if info.context and info.context.get("permission_get_film_details"):
|
||||
return queryset
|
||||
raise Exception("Not authorized to access film details.")
|
||||
|
||||
class FilmType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Film
|
||||
|
||||
@classmethod
|
||||
def get_queryset(cls, queryset, info):
|
||||
if info.context and info.context.get("permission_get_film"):
|
||||
return queryset
|
||||
raise Exception("Not authorized to access film.")
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
film_details = graphene.Field(
|
||||
FilmDetailsType, id=graphene.ID(required=True)
|
||||
)
|
||||
film = graphene.Field(FilmType, id=graphene.ID(required=True))
|
||||
|
||||
def resolve_film_details(self, info, id):
|
||||
return (
|
||||
FilmDetailsType.get_queryset(FilmDetails.objects, info)
|
||||
.filter(id=id)
|
||||
.last()
|
||||
)
|
||||
|
||||
def resolve_film(self, info, id):
|
||||
return FilmType.get_queryset(Film.objects, info).filter(id=id).last()
|
||||
|
||||
self.schema = graphene.Schema(query=Query)
|
||||
|
||||
self.films = [
|
||||
Film.objects.create(
|
||||
genre="do",
|
||||
),
|
||||
Film.objects.create(
|
||||
genre="ac",
|
||||
),
|
||||
]
|
||||
|
||||
self.film_details = [
|
||||
FilmDetails.objects.create(
|
||||
film=self.films[0],
|
||||
),
|
||||
FilmDetails.objects.create(
|
||||
film=self.films[1],
|
||||
),
|
||||
]
|
||||
|
||||
def test_get_queryset_called_on_field(self):
|
||||
# A user tries to access a film
|
||||
query = """
|
||||
query getFilm($id: ID!) {
|
||||
film(id: $id) {
|
||||
genre
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
# With `permission_get_film`
|
||||
result = self.schema.execute(
|
||||
query,
|
||||
variables={"id": self.films[0].id},
|
||||
context_value={"permission_get_film": True},
|
||||
)
|
||||
assert not result.errors
|
||||
assert result.data["film"] == {
|
||||
"genre": "DO",
|
||||
}
|
||||
|
||||
# Without `permission_get_film`
|
||||
result = self.schema.execute(
|
||||
query,
|
||||
variables={"id": self.films[1].id},
|
||||
context_value={"permission_get_film": False},
|
||||
)
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0].message == "Not authorized to access film."
|
||||
|
||||
# A user tries to access a film details
|
||||
query = """
|
||||
query getFilmDetails($id: ID!) {
|
||||
filmDetails(id: $id) {
|
||||
location
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
# With `permission_get_film`
|
||||
result = self.schema.execute(
|
||||
query,
|
||||
variables={"id": self.film_details[0].id},
|
||||
context_value={"permission_get_film_details": True},
|
||||
)
|
||||
assert not result.errors
|
||||
assert result.data == {"filmDetails": {"location": ""}}
|
||||
|
||||
# Without `permission_get_film`
|
||||
result = self.schema.execute(
|
||||
query,
|
||||
variables={"id": self.film_details[0].id},
|
||||
context_value={"permission_get_film_details": False},
|
||||
)
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0].message == "Not authorized to access film details."
|
||||
|
||||
def test_get_queryset_called_on_foreignkey(self, django_assert_num_queries):
|
||||
# A user tries to access a film details through a film
|
||||
query = """
|
||||
query getFilm($id: ID!) {
|
||||
film(id: $id) {
|
||||
genre
|
||||
details {
|
||||
location
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
# With `permission_get_film_details`
|
||||
with django_assert_num_queries(2):
|
||||
result = self.schema.execute(
|
||||
query,
|
||||
variables={"id": self.films[0].id},
|
||||
context_value={
|
||||
"permission_get_film": True,
|
||||
"permission_get_film_details": True,
|
||||
},
|
||||
)
|
||||
assert not result.errors
|
||||
assert result.data["film"] == {
|
||||
"genre": "DO",
|
||||
"details": {"location": ""},
|
||||
}
|
||||
|
||||
# Without `permission_get_film_details`
|
||||
with django_assert_num_queries(1):
|
||||
result = self.schema.execute(
|
||||
query,
|
||||
variables={"id": self.films[0].id},
|
||||
context_value={
|
||||
"permission_get_film": True,
|
||||
"permission_get_film_details": False,
|
||||
},
|
||||
)
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0].message == "Not authorized to access film details."
|
||||
|
||||
# A user tries to access a film through a film details
|
||||
query = """
|
||||
query getFilmDetails($id: ID!) {
|
||||
filmDetails(id: $id) {
|
||||
location
|
||||
film {
|
||||
genre
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
# With `permission_get_film`
|
||||
with django_assert_num_queries(2):
|
||||
result = self.schema.execute(
|
||||
query,
|
||||
variables={"id": self.film_details[0].id},
|
||||
context_value={
|
||||
"permission_get_film": True,
|
||||
"permission_get_film_details": True,
|
||||
},
|
||||
)
|
||||
assert not result.errors
|
||||
assert result.data["filmDetails"] == {
|
||||
"location": "",
|
||||
"film": {"genre": "DO"},
|
||||
}
|
||||
|
||||
# Without `permission_get_film`
|
||||
with django_assert_num_queries(1):
|
||||
result = self.schema.execute(
|
||||
query,
|
||||
variables={"id": self.film_details[1].id},
|
||||
context_value={
|
||||
"permission_get_film": False,
|
||||
"permission_get_film_details": True,
|
||||
},
|
||||
)
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0].message == "Not authorized to access film."
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import datetime
|
||||
import base64
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
from django.db import models
|
||||
|
@ -15,7 +15,16 @@ from ..compat import IntegerRangeField, MissingType
|
|||
from ..fields import DjangoConnectionField
|
||||
from ..types import DjangoObjectType
|
||||
from ..utils import DJANGO_FILTER_INSTALLED
|
||||
from .models import Article, CNNReporter, Film, FilmDetails, Person, Pet, Reporter
|
||||
from .models import (
|
||||
APNewsReporter,
|
||||
Article,
|
||||
CNNReporter,
|
||||
Film,
|
||||
FilmDetails,
|
||||
Person,
|
||||
Pet,
|
||||
Reporter,
|
||||
)
|
||||
|
||||
|
||||
def test_should_query_only_fields():
|
||||
|
@ -117,15 +126,14 @@ def test_should_query_well():
|
|||
@pytest.mark.skipif(IntegerRangeField is MissingType, reason="RangeField should exist")
|
||||
def test_should_query_postgres_fields():
|
||||
from django.contrib.postgres.fields import (
|
||||
IntegerRangeField,
|
||||
ArrayField,
|
||||
JSONField,
|
||||
HStoreField,
|
||||
IntegerRangeField,
|
||||
)
|
||||
|
||||
class Event(models.Model):
|
||||
ages = IntegerRangeField(help_text="The age ranges")
|
||||
data = JSONField(help_text="Data")
|
||||
data = models.JSONField(help_text="Data")
|
||||
store = HStoreField()
|
||||
tags = ArrayField(models.CharField(max_length=50))
|
||||
|
||||
|
@ -347,7 +355,7 @@ def test_should_query_connectionfields():
|
|||
|
||||
|
||||
def test_should_keep_annotations():
|
||||
from django.db.models import Count, Avg
|
||||
from django.db.models import Avg, Count
|
||||
|
||||
class ReporterType(DjangoObjectType):
|
||||
class Meta:
|
||||
|
@ -509,7 +517,7 @@ def test_should_query_node_filtering_with_distinct_queryset():
|
|||
).distinct()
|
||||
|
||||
f = Film.objects.create()
|
||||
fd = FilmDetails.objects.create(location="Berlin", film=f)
|
||||
FilmDetails.objects.create(location="Berlin", film=f)
|
||||
|
||||
schema = graphene.Schema(query=Query)
|
||||
query = """
|
||||
|
@ -632,7 +640,7 @@ def test_should_enforce_first_or_last(graphene_settings):
|
|||
class Query(graphene.ObjectType):
|
||||
all_reporters = DjangoConnectionField(ReporterType)
|
||||
|
||||
r = Reporter.objects.create(
|
||||
Reporter.objects.create(
|
||||
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
|
||||
)
|
||||
|
||||
|
@ -674,7 +682,7 @@ def test_should_error_if_first_is_greater_than_max(graphene_settings):
|
|||
|
||||
assert Query.all_reporters.max_limit == 100
|
||||
|
||||
r = Reporter.objects.create(
|
||||
Reporter.objects.create(
|
||||
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
|
||||
)
|
||||
|
||||
|
@ -716,7 +724,7 @@ def test_should_error_if_last_is_greater_than_max(graphene_settings):
|
|||
|
||||
assert Query.all_reporters.max_limit == 100
|
||||
|
||||
r = Reporter.objects.create(
|
||||
Reporter.objects.create(
|
||||
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
|
||||
)
|
||||
|
||||
|
@ -780,7 +788,7 @@ def test_should_query_promise_connectionfields():
|
|||
|
||||
|
||||
def test_should_query_connectionfields_with_last():
|
||||
r = Reporter.objects.create(
|
||||
Reporter.objects.create(
|
||||
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
|
||||
)
|
||||
|
||||
|
@ -817,11 +825,11 @@ def test_should_query_connectionfields_with_last():
|
|||
|
||||
|
||||
def test_should_query_connectionfields_with_manager():
|
||||
r = Reporter.objects.create(
|
||||
Reporter.objects.create(
|
||||
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
|
||||
)
|
||||
|
||||
r = Reporter.objects.create(
|
||||
Reporter.objects.create(
|
||||
first_name="John", last_name="NotDoe", email="johndoe@example.com", a_choice=1
|
||||
)
|
||||
|
||||
|
@ -1065,11 +1073,306 @@ def test_proxy_model_support():
|
|||
assert result.data == expected
|
||||
|
||||
|
||||
def test_should_resolve_get_queryset_connectionfields():
|
||||
reporter_1 = Reporter.objects.create(
|
||||
def test_model_inheritance_support_reverse_relationships():
|
||||
"""
|
||||
This test asserts that we can query reverse relationships for all Reporters and proxied Reporters and multi table Reporters.
|
||||
"""
|
||||
|
||||
class FilmType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Film
|
||||
fields = "__all__"
|
||||
|
||||
class ReporterType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
use_connection = True
|
||||
fields = "__all__"
|
||||
|
||||
class CNNReporterType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = CNNReporter
|
||||
interfaces = (Node,)
|
||||
use_connection = True
|
||||
fields = "__all__"
|
||||
|
||||
class APNewsReporterType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = APNewsReporter
|
||||
interfaces = (Node,)
|
||||
use_connection = True
|
||||
fields = "__all__"
|
||||
|
||||
film = Film.objects.create(genre="do")
|
||||
|
||||
reporter = Reporter.objects.create(
|
||||
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
|
||||
)
|
||||
reporter_2 = CNNReporter.objects.create(
|
||||
|
||||
cnn_reporter = CNNReporter.objects.create(
|
||||
first_name="Some",
|
||||
last_name="Guy",
|
||||
email="someguy@cnn.com",
|
||||
a_choice=1,
|
||||
reporter_type=2, # set this guy to be CNN
|
||||
)
|
||||
|
||||
ap_news_reporter = APNewsReporter.objects.create(
|
||||
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
|
||||
)
|
||||
|
||||
film.reporters.add(cnn_reporter, ap_news_reporter)
|
||||
film.save()
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
all_reporters = DjangoConnectionField(ReporterType)
|
||||
cnn_reporters = DjangoConnectionField(CNNReporterType)
|
||||
ap_news_reporters = DjangoConnectionField(APNewsReporterType)
|
||||
|
||||
schema = graphene.Schema(query=Query)
|
||||
query = """
|
||||
query ProxyModelQuery {
|
||||
allReporters {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
films {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cnnReporters {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
films {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
apNewsReporters {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
films {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
expected = {
|
||||
"allReporters": {
|
||||
"edges": [
|
||||
{
|
||||
"node": {
|
||||
"id": to_global_id("ReporterType", reporter.id),
|
||||
"films": [],
|
||||
},
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"id": to_global_id("ReporterType", cnn_reporter.id),
|
||||
"films": [{"id": f"{film.id}"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"id": to_global_id("ReporterType", ap_news_reporter.id),
|
||||
"films": [{"id": f"{film.id}"}],
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
"cnnReporters": {
|
||||
"edges": [
|
||||
{
|
||||
"node": {
|
||||
"id": to_global_id("CNNReporterType", cnn_reporter.id),
|
||||
"films": [{"id": f"{film.id}"}],
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"apNewsReporters": {
|
||||
"edges": [
|
||||
{
|
||||
"node": {
|
||||
"id": to_global_id("APNewsReporterType", ap_news_reporter.id),
|
||||
"films": [{"id": f"{film.id}"}],
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = schema.execute(query)
|
||||
assert result.data == expected
|
||||
|
||||
|
||||
def test_model_inheritance_support_local_relationships():
|
||||
"""
|
||||
This test asserts that we can query local relationships for all Reporters and proxied Reporters and multi table Reporters.
|
||||
"""
|
||||
|
||||
class PersonType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Person
|
||||
fields = "__all__"
|
||||
|
||||
class ReporterType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
use_connection = True
|
||||
fields = "__all__"
|
||||
|
||||
class CNNReporterType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = CNNReporter
|
||||
interfaces = (Node,)
|
||||
use_connection = True
|
||||
fields = "__all__"
|
||||
|
||||
class APNewsReporterType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = APNewsReporter
|
||||
interfaces = (Node,)
|
||||
use_connection = True
|
||||
fields = "__all__"
|
||||
|
||||
film = Film.objects.create(genre="do")
|
||||
|
||||
reporter = Reporter.objects.create(
|
||||
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
|
||||
)
|
||||
|
||||
reporter_fan = Person.objects.create(name="Reporter Fan")
|
||||
|
||||
reporter.fans.add(reporter_fan)
|
||||
reporter.save()
|
||||
|
||||
cnn_reporter = CNNReporter.objects.create(
|
||||
first_name="Some",
|
||||
last_name="Guy",
|
||||
email="someguy@cnn.com",
|
||||
a_choice=1,
|
||||
reporter_type=2, # set this guy to be CNN
|
||||
)
|
||||
cnn_fan = Person.objects.create(name="CNN Fan")
|
||||
cnn_reporter.fans.add(cnn_fan)
|
||||
cnn_reporter.save()
|
||||
|
||||
ap_news_reporter = APNewsReporter.objects.create(
|
||||
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
|
||||
)
|
||||
ap_news_fan = Person.objects.create(name="AP News Fan")
|
||||
ap_news_reporter.fans.add(ap_news_fan)
|
||||
ap_news_reporter.save()
|
||||
|
||||
film.reporters.add(cnn_reporter, ap_news_reporter)
|
||||
film.save()
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
all_reporters = DjangoConnectionField(ReporterType)
|
||||
cnn_reporters = DjangoConnectionField(CNNReporterType)
|
||||
ap_news_reporters = DjangoConnectionField(APNewsReporterType)
|
||||
|
||||
schema = graphene.Schema(query=Query)
|
||||
query = """
|
||||
query ProxyModelQuery {
|
||||
allReporters {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
fans {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cnnReporters {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
fans {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
apNewsReporters {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
fans {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
expected = {
|
||||
"allReporters": {
|
||||
"edges": [
|
||||
{
|
||||
"node": {
|
||||
"id": to_global_id("ReporterType", reporter.id),
|
||||
"fans": [{"name": f"{reporter_fan.name}"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"id": to_global_id("ReporterType", cnn_reporter.id),
|
||||
"fans": [{"name": f"{cnn_fan.name}"}],
|
||||
},
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"id": to_global_id("ReporterType", ap_news_reporter.id),
|
||||
"fans": [{"name": f"{ap_news_fan.name}"}],
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
"cnnReporters": {
|
||||
"edges": [
|
||||
{
|
||||
"node": {
|
||||
"id": to_global_id("CNNReporterType", cnn_reporter.id),
|
||||
"fans": [{"name": f"{cnn_fan.name}"}],
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"apNewsReporters": {
|
||||
"edges": [
|
||||
{
|
||||
"node": {
|
||||
"id": to_global_id("APNewsReporterType", ap_news_reporter.id),
|
||||
"fans": [{"name": f"{ap_news_fan.name}"}],
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
result = schema.execute(query)
|
||||
assert result.data == expected
|
||||
|
||||
|
||||
def test_should_resolve_get_queryset_connectionfields():
|
||||
Reporter.objects.create(
|
||||
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
|
||||
)
|
||||
CNNReporter.objects.create(
|
||||
first_name="Some",
|
||||
last_name="Guy",
|
||||
email="someguy@cnn.com",
|
||||
|
@ -1111,10 +1414,10 @@ def test_should_resolve_get_queryset_connectionfields():
|
|||
|
||||
|
||||
def test_connection_should_limit_after_to_list_length():
|
||||
reporter_1 = Reporter.objects.create(
|
||||
Reporter.objects.create(
|
||||
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
|
||||
)
|
||||
reporter_2 = Reporter.objects.create(
|
||||
Reporter.objects.create(
|
||||
first_name="Some", last_name="Guy", email="someguy@cnn.com", a_choice=1
|
||||
)
|
||||
|
||||
|
@ -1141,19 +1444,19 @@ def test_connection_should_limit_after_to_list_length():
|
|||
"""
|
||||
|
||||
after = base64.b64encode(b"arrayconnection:10").decode()
|
||||
result = schema.execute(query, variable_values=dict(after=after))
|
||||
result = schema.execute(query, variable_values={"after": after})
|
||||
expected = {"allReporters": {"edges": []}}
|
||||
assert not result.errors
|
||||
assert result.data == expected
|
||||
|
||||
|
||||
REPORTERS = [
|
||||
dict(
|
||||
first_name=f"First {i}",
|
||||
last_name=f"Last {i}",
|
||||
email=f"johndoe+{i}@example.com",
|
||||
a_choice=1,
|
||||
)
|
||||
{
|
||||
"first_name": f"First {i}",
|
||||
"last_name": f"Last {i}",
|
||||
"email": f"johndoe+{i}@example.com",
|
||||
"a_choice": 1,
|
||||
}
|
||||
for i in range(6)
|
||||
]
|
||||
|
||||
|
@ -1228,7 +1531,7 @@ def test_should_have_next_page(graphene_settings):
|
|||
assert result.data["allReporters"]["pageInfo"]["hasNextPage"]
|
||||
|
||||
last_result = result.data["allReporters"]["pageInfo"]["endCursor"]
|
||||
result2 = schema.execute(query, variable_values=dict(first=4, after=last_result))
|
||||
result2 = schema.execute(query, variable_values={"first": 4, "after": last_result})
|
||||
assert not result2.errors
|
||||
assert len(result2.data["allReporters"]["edges"]) == 2
|
||||
assert not result2.data["allReporters"]["pageInfo"]["hasNextPage"]
|
||||
|
@ -1319,7 +1622,7 @@ class TestBackwardPagination:
|
|||
after = base64.b64encode(b"arrayconnection:0").decode()
|
||||
result = schema.execute(
|
||||
query_first_last_and_after,
|
||||
variable_values=dict(after=after),
|
||||
variable_values={"after": after},
|
||||
)
|
||||
assert not result.errors
|
||||
assert len(result.data["allReporters"]["edges"]) == 3
|
||||
|
@ -1351,7 +1654,7 @@ class TestBackwardPagination:
|
|||
before = base64.b64encode(b"arrayconnection:5").decode()
|
||||
result = schema.execute(
|
||||
query_first_last_and_after,
|
||||
variable_values=dict(before=before),
|
||||
variable_values={"before": before},
|
||||
)
|
||||
assert not result.errors
|
||||
assert len(result.data["allReporters"]["edges"]) == 1
|
||||
|
@ -1407,7 +1710,7 @@ def test_should_preserve_prefetch_related(django_assert_num_queries):
|
|||
"""
|
||||
schema = graphene.Schema(query=Query)
|
||||
|
||||
with django_assert_num_queries(3) as captured:
|
||||
with django_assert_num_queries(3):
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
|
||||
|
@ -1574,7 +1877,7 @@ def test_connection_should_forbid_offset_filtering_with_before():
|
|||
}
|
||||
"""
|
||||
before = base64.b64encode(b"arrayconnection:2").decode()
|
||||
result = schema.execute(query, variable_values=dict(before=before))
|
||||
result = schema.execute(query, variable_values={"before": before})
|
||||
expected_error = "You can't provide a `before` value at the same time as an `offset` value to properly paginate the `allReporters` connection."
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0].message == expected_error
|
||||
|
@ -1610,7 +1913,7 @@ def test_connection_should_allow_offset_filtering_with_after():
|
|||
"""
|
||||
|
||||
after = base64.b64encode(b"arrayconnection:0").decode()
|
||||
result = schema.execute(query, variable_values=dict(after=after))
|
||||
result = schema.execute(query, variable_values={"after": after})
|
||||
assert not result.errors
|
||||
expected = {
|
||||
"allReporters": {
|
||||
|
@ -1646,7 +1949,7 @@ def test_connection_should_succeed_if_last_higher_than_number_of_objects():
|
|||
}
|
||||
"""
|
||||
|
||||
result = schema.execute(query, variable_values=dict(last=2))
|
||||
result = schema.execute(query, variable_values={"last": 2})
|
||||
assert not result.errors
|
||||
expected = {"allReporters": {"edges": []}}
|
||||
assert result.data == expected
|
||||
|
@ -1656,7 +1959,7 @@ def test_connection_should_succeed_if_last_higher_than_number_of_objects():
|
|||
Reporter.objects.create(first_name="Jane", last_name="Roe")
|
||||
Reporter.objects.create(first_name="Some", last_name="Lady")
|
||||
|
||||
result = schema.execute(query, variable_values=dict(last=2))
|
||||
result = schema.execute(query, variable_values={"last": 2})
|
||||
assert not result.errors
|
||||
expected = {
|
||||
"allReporters": {
|
||||
|
@ -1668,7 +1971,7 @@ def test_connection_should_succeed_if_last_higher_than_number_of_objects():
|
|||
}
|
||||
assert result.data == expected
|
||||
|
||||
result = schema.execute(query, variable_values=dict(last=4))
|
||||
result = schema.execute(query, variable_values={"last": 4})
|
||||
assert not result.errors
|
||||
expected = {
|
||||
"allReporters": {
|
||||
|
@ -1682,7 +1985,7 @@ def test_connection_should_succeed_if_last_higher_than_number_of_objects():
|
|||
}
|
||||
assert result.data == expected
|
||||
|
||||
result = schema.execute(query, variable_values=dict(last=20))
|
||||
result = schema.execute(query, variable_values={"last": 20})
|
||||
assert not result.errors
|
||||
expected = {
|
||||
"allReporters": {
|
||||
|
@ -1719,7 +2022,7 @@ def test_should_query_nullable_foreign_key():
|
|||
schema = graphene.Schema(query=Query)
|
||||
|
||||
person = Person.objects.create(name="Jane")
|
||||
pets = [
|
||||
[
|
||||
Pet.objects.create(name="Stray dog", age=1),
|
||||
Pet.objects.create(name="Jane's dog", owner=person, age=1),
|
||||
]
|
||||
|
@ -1759,3 +2062,74 @@ def test_should_query_nullable_foreign_key():
|
|||
assert result.data["person"] == {
|
||||
"pets": [{"name": "Jane's dog"}],
|
||||
}
|
||||
|
||||
|
||||
def test_should_query_nullable_one_to_one_relation_with_custom_resolver():
|
||||
class FilmType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Film
|
||||
|
||||
@classmethod
|
||||
def get_queryset(cls, queryset, info):
|
||||
return queryset
|
||||
|
||||
class FilmDetailsType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = FilmDetails
|
||||
|
||||
@classmethod
|
||||
def get_queryset(cls, queryset, info):
|
||||
return queryset
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
film = graphene.Field(FilmType, genre=graphene.String(required=True))
|
||||
film_details = graphene.Field(
|
||||
FilmDetailsType, location=graphene.String(required=True)
|
||||
)
|
||||
|
||||
def resolve_film(self, info, genre):
|
||||
return Film.objects.filter(genre=genre).first()
|
||||
|
||||
def resolve_film_details(self, info, location):
|
||||
return FilmDetails.objects.filter(location=location).first()
|
||||
|
||||
schema = graphene.Schema(query=Query)
|
||||
|
||||
Film.objects.create(genre="do")
|
||||
FilmDetails.objects.create(location="London")
|
||||
|
||||
query_film = """
|
||||
query getFilm($genre: String!) {
|
||||
film(genre: $genre) {
|
||||
genre
|
||||
details {
|
||||
location
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
query_film_details = """
|
||||
query getFilmDetails($location: String!) {
|
||||
filmDetails(location: $location) {
|
||||
location
|
||||
film {
|
||||
genre
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = schema.execute(query_film, variables={"genre": "do"})
|
||||
assert not result.errors
|
||||
assert result.data["film"] == {
|
||||
"genre": "DO",
|
||||
"details": None,
|
||||
}
|
||||
|
||||
result = schema.execute(query_film_details, variables={"location": "London"})
|
||||
assert not result.errors
|
||||
assert result.data["filmDetails"] == {
|
||||
"location": "London",
|
||||
"film": None,
|
||||
}
|
||||
|
|
|
@ -33,17 +33,18 @@ def test_should_map_fields_correctly():
|
|||
fields = "__all__"
|
||||
|
||||
fields = list(ReporterType2._meta.fields.keys())
|
||||
assert fields[:-2] == [
|
||||
assert fields[:-3] == [
|
||||
"id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email",
|
||||
"pets",
|
||||
"a_choice",
|
||||
"fans",
|
||||
"reporter_type",
|
||||
]
|
||||
|
||||
assert sorted(fields[-2:]) == ["articles", "films"]
|
||||
assert sorted(fields[-3:]) == ["apnewsreporter", "articles", "films"]
|
||||
|
||||
|
||||
def test_should_map_only_few_fields():
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import warnings
|
||||
from collections import OrderedDict, defaultdict
|
||||
from textwrap import dedent
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.db import models
|
||||
from unittest.mock import patch
|
||||
|
||||
from graphene import Connection, Field, Interface, ObjectType, Schema, String
|
||||
from graphene.relay import Node
|
||||
|
@ -11,8 +12,10 @@ from graphene.relay import Node
|
|||
from .. import registry
|
||||
from ..filter import DjangoFilterConnectionField
|
||||
from ..types import DjangoObjectType, DjangoObjectTypeOptions
|
||||
from .models import Article as ArticleModel
|
||||
from .models import Reporter as ReporterModel
|
||||
from .models import (
|
||||
Article as ArticleModel,
|
||||
Reporter as ReporterModel,
|
||||
)
|
||||
|
||||
|
||||
class Reporter(DjangoObjectType):
|
||||
|
@ -67,16 +70,17 @@ def test_django_get_node(get):
|
|||
def test_django_objecttype_map_correct_fields():
|
||||
fields = Reporter._meta.fields
|
||||
fields = list(fields.keys())
|
||||
assert fields[:-2] == [
|
||||
assert fields[:-3] == [
|
||||
"id",
|
||||
"first_name",
|
||||
"last_name",
|
||||
"email",
|
||||
"pets",
|
||||
"a_choice",
|
||||
"fans",
|
||||
"reporter_type",
|
||||
]
|
||||
assert sorted(fields[-2:]) == ["articles", "films"]
|
||||
assert sorted(fields[-3:]) == ["apnewsreporter", "articles", "films"]
|
||||
|
||||
|
||||
def test_django_objecttype_with_node_have_correct_fields():
|
||||
|
@ -396,7 +400,7 @@ def test_django_objecttype_fields_exist_on_model():
|
|||
with pytest.warns(
|
||||
UserWarning,
|
||||
match=r"Field name .* matches an attribute on Django model .* but it's not a model field",
|
||||
) as record:
|
||||
):
|
||||
|
||||
class Reporter2(DjangoObjectType):
|
||||
class Meta:
|
||||
|
@ -404,7 +408,8 @@ def test_django_objecttype_fields_exist_on_model():
|
|||
fields = ["first_name", "some_method", "email"]
|
||||
|
||||
# Don't warn if selecting a custom field
|
||||
with pytest.warns(None) as record:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
|
||||
class Reporter3(DjangoObjectType):
|
||||
custom_field = String()
|
||||
|
@ -413,8 +418,6 @@ def test_django_objecttype_fields_exist_on_model():
|
|||
model = ReporterModel
|
||||
fields = ["first_name", "custom_field", "email"]
|
||||
|
||||
assert len(record) == 0
|
||||
|
||||
|
||||
@with_local_registry
|
||||
def test_django_objecttype_exclude_fields_exist_on_model():
|
||||
|
@ -442,15 +445,14 @@ def test_django_objecttype_exclude_fields_exist_on_model():
|
|||
exclude = ["custom_field"]
|
||||
|
||||
# Don't warn on exclude fields
|
||||
with pytest.warns(None) as record:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
|
||||
class Reporter4(DjangoObjectType):
|
||||
class Meta:
|
||||
model = ReporterModel
|
||||
exclude = ["email", "first_name"]
|
||||
|
||||
assert len(record) == 0
|
||||
|
||||
|
||||
@with_local_registry
|
||||
def test_django_objecttype_neither_fields_nor_exclude():
|
||||
|
@ -464,24 +466,22 @@ def test_django_objecttype_neither_fields_nor_exclude():
|
|||
class Meta:
|
||||
model = ReporterModel
|
||||
|
||||
with pytest.warns(None) as record:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
|
||||
class Reporter2(DjangoObjectType):
|
||||
class Meta:
|
||||
model = ReporterModel
|
||||
fields = ["email"]
|
||||
|
||||
assert len(record) == 0
|
||||
|
||||
with pytest.warns(None) as record:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("error")
|
||||
|
||||
class Reporter3(DjangoObjectType):
|
||||
class Meta:
|
||||
model = ReporterModel
|
||||
exclude = ["email"]
|
||||
|
||||
assert len(record) == 0
|
||||
|
||||
|
||||
def custom_enum_name(field):
|
||||
return f"CustomEnum{field.name.title()}"
|
||||
|
@ -658,6 +658,122 @@ class TestDjangoObjectType:
|
|||
}"""
|
||||
)
|
||||
|
||||
def test_django_objecttype_convert_choices_global_false(
|
||||
self, graphene_settings, PetModel
|
||||
):
|
||||
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CONVERT = False
|
||||
|
||||
class Pet(DjangoObjectType):
|
||||
class Meta:
|
||||
model = PetModel
|
||||
fields = "__all__"
|
||||
|
||||
class Query(ObjectType):
|
||||
pet = Field(Pet)
|
||||
|
||||
schema = Schema(query=Query)
|
||||
|
||||
assert str(schema) == dedent(
|
||||
"""\
|
||||
type Query {
|
||||
pet: Pet
|
||||
}
|
||||
|
||||
type Pet {
|
||||
id: ID!
|
||||
kind: String!
|
||||
cuteness: Int!
|
||||
}"""
|
||||
)
|
||||
|
||||
def test_django_objecttype_convert_choices_true_global_false(
|
||||
self, graphene_settings, PetModel
|
||||
):
|
||||
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CONVERT = False
|
||||
|
||||
class Pet(DjangoObjectType):
|
||||
class Meta:
|
||||
model = PetModel
|
||||
fields = "__all__"
|
||||
convert_choices_to_enum = True
|
||||
|
||||
class Query(ObjectType):
|
||||
pet = Field(Pet)
|
||||
|
||||
schema = Schema(query=Query)
|
||||
|
||||
assert str(schema) == dedent(
|
||||
"""\
|
||||
type Query {
|
||||
pet: Pet
|
||||
}
|
||||
|
||||
type Pet {
|
||||
id: ID!
|
||||
kind: TestsPetModelKindChoices!
|
||||
cuteness: TestsPetModelCutenessChoices!
|
||||
}
|
||||
|
||||
\"""An enumeration.\"""
|
||||
enum TestsPetModelKindChoices {
|
||||
\"""Cat\"""
|
||||
CAT
|
||||
|
||||
\"""Dog\"""
|
||||
DOG
|
||||
}
|
||||
|
||||
\"""An enumeration.\"""
|
||||
enum TestsPetModelCutenessChoices {
|
||||
\"""Kind of cute\"""
|
||||
A_1
|
||||
|
||||
\"""Pretty cute\"""
|
||||
A_2
|
||||
|
||||
\"""OMG SO CUTE!!!\"""
|
||||
A_3
|
||||
}"""
|
||||
)
|
||||
|
||||
def test_django_objecttype_convert_choices_enum_list_global_false(
|
||||
self, graphene_settings, PetModel
|
||||
):
|
||||
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CONVERT = False
|
||||
|
||||
class Pet(DjangoObjectType):
|
||||
class Meta:
|
||||
model = PetModel
|
||||
convert_choices_to_enum = ["kind"]
|
||||
fields = "__all__"
|
||||
|
||||
class Query(ObjectType):
|
||||
pet = Field(Pet)
|
||||
|
||||
schema = Schema(query=Query)
|
||||
|
||||
assert str(schema) == dedent(
|
||||
"""\
|
||||
type Query {
|
||||
pet: Pet
|
||||
}
|
||||
|
||||
type Pet {
|
||||
id: ID!
|
||||
kind: TestsPetModelKindChoices!
|
||||
cuteness: Int!
|
||||
}
|
||||
|
||||
\"""An enumeration.\"""
|
||||
enum TestsPetModelKindChoices {
|
||||
\"""Cat\"""
|
||||
CAT
|
||||
|
||||
\"""Dog\"""
|
||||
DOG
|
||||
}"""
|
||||
)
|
||||
|
||||
|
||||
@with_local_registry
|
||||
def test_django_objecttype_name_connection_propagation():
|
||||
|
|
|
@ -1,12 +1,12 @@
|
|||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.utils.translation import gettext_lazy
|
||||
from unittest.mock import patch
|
||||
|
||||
from ..utils import camelize, get_model_fields, GraphQLTestCase
|
||||
from .models import Film, Reporter
|
||||
from ..utils import GraphQLTestCase, camelize, get_model_fields, get_reverse_fields
|
||||
from ..utils.testing import graphql_query
|
||||
from .models import APNewsReporter, CNNReporter, Film, Reporter
|
||||
|
||||
|
||||
def test_get_model_fields_no_duplication():
|
||||
|
@ -19,6 +19,18 @@ def test_get_model_fields_no_duplication():
|
|||
assert len(film_fields) == len(film_name_set)
|
||||
|
||||
|
||||
def test_get_reverse_fields_includes_proxied_models():
|
||||
reporter_fields = get_reverse_fields(Reporter, [])
|
||||
cnn_reporter_fields = get_reverse_fields(CNNReporter, [])
|
||||
ap_news_reporter_fields = get_reverse_fields(APNewsReporter, [])
|
||||
|
||||
assert (
|
||||
len(list(reporter_fields))
|
||||
== len(list(cnn_reporter_fields))
|
||||
== len(list(ap_news_reporter_fields))
|
||||
)
|
||||
|
||||
|
||||
def test_camelize():
|
||||
assert camelize({}) == {}
|
||||
assert camelize("value_a") == "value_a"
|
||||
|
|
|
@ -1,13 +1,10 @@
|
|||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from http import HTTPStatus
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.db import connection
|
||||
|
||||
from graphene_django.settings import graphene_settings
|
||||
|
||||
from .models import Pet
|
||||
|
||||
try:
|
||||
|
@ -31,13 +28,17 @@ def response_json(response):
|
|||
return json.loads(response.content.decode())
|
||||
|
||||
|
||||
j = lambda **kwargs: json.dumps(kwargs)
|
||||
jl = lambda **kwargs: json.dumps([kwargs])
|
||||
def j(**kwargs):
|
||||
return json.dumps(kwargs)
|
||||
|
||||
|
||||
def jl(**kwargs):
|
||||
return json.dumps([kwargs])
|
||||
|
||||
|
||||
def test_graphiql_is_enabled(client):
|
||||
response = client.get(url_string(), HTTP_ACCEPT="text/html")
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response["Content-Type"].split(";")[0] == "text/html"
|
||||
|
||||
|
||||
|
@ -46,7 +47,7 @@ def test_qfactor_graphiql(client):
|
|||
url_string(query="{test}"),
|
||||
HTTP_ACCEPT="application/json;q=0.8, text/html;q=0.9",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response["Content-Type"].split(";")[0] == "text/html"
|
||||
|
||||
|
||||
|
@ -55,7 +56,7 @@ def test_qfactor_json(client):
|
|||
url_string(query="{test}"),
|
||||
HTTP_ACCEPT="text/html;q=0.8, application/json;q=0.9",
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response["Content-Type"].split(";")[0] == "application/json"
|
||||
assert response_json(response) == {"data": {"test": "Hello World"}}
|
||||
|
||||
|
@ -63,7 +64,7 @@ def test_qfactor_json(client):
|
|||
def test_allows_get_with_query_param(client):
|
||||
response = client.get(url_string(query="{test}"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == {"data": {"test": "Hello World"}}
|
||||
|
||||
|
||||
|
@ -75,7 +76,7 @@ def test_allows_get_with_variable_values(client):
|
|||
)
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == {"data": {"test": "Hello Dolly"}}
|
||||
|
||||
|
||||
|
@ -94,7 +95,7 @@ def test_allows_get_with_operation_name(client):
|
|||
)
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == {
|
||||
"data": {"test": "Hello World", "shared": "Hello Everyone"}
|
||||
}
|
||||
|
@ -103,7 +104,7 @@ def test_allows_get_with_operation_name(client):
|
|||
def test_reports_validation_errors(client):
|
||||
response = client.get(url_string(query="{ test, unknownOne, unknownTwo }"))
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response_json(response) == {
|
||||
"errors": [
|
||||
{
|
||||
|
@ -128,7 +129,7 @@ def test_errors_when_missing_operation_name(client):
|
|||
)
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response_json(response) == {
|
||||
"errors": [
|
||||
{
|
||||
|
@ -146,7 +147,7 @@ def test_errors_when_sending_a_mutation_via_get(client):
|
|||
"""
|
||||
)
|
||||
)
|
||||
assert response.status_code == 405
|
||||
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
|
||||
assert response_json(response) == {
|
||||
"errors": [
|
||||
{"message": "Can only perform a mutation operation from a POST request."}
|
||||
|
@ -165,7 +166,7 @@ def test_errors_when_selecting_a_mutation_within_a_get(client):
|
|||
)
|
||||
)
|
||||
|
||||
assert response.status_code == 405
|
||||
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
|
||||
assert response_json(response) == {
|
||||
"errors": [
|
||||
{"message": "Can only perform a mutation operation from a POST request."}
|
||||
|
@ -184,14 +185,14 @@ def test_allows_mutation_to_exist_within_a_get(client):
|
|||
)
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == {"data": {"test": "Hello World"}}
|
||||
|
||||
|
||||
def test_allows_post_with_json_encoding(client):
|
||||
response = client.post(url_string(), j(query="{test}"), "application/json")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == {"data": {"test": "Hello World"}}
|
||||
|
||||
|
||||
|
@ -200,7 +201,7 @@ def test_batch_allows_post_with_json_encoding(client):
|
|||
batch_url_string(), jl(id=1, query="{test}"), "application/json"
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == [
|
||||
{"id": 1, "data": {"test": "Hello World"}, "status": 200}
|
||||
]
|
||||
|
@ -209,7 +210,7 @@ def test_batch_allows_post_with_json_encoding(client):
|
|||
def test_batch_fails_if_is_empty(client):
|
||||
response = client.post(batch_url_string(), "[]", "application/json")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response_json(response) == {
|
||||
"errors": [{"message": "Received an empty list in the batch request."}]
|
||||
}
|
||||
|
@ -222,18 +223,18 @@ def test_allows_sending_a_mutation_via_post(client):
|
|||
"application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}}
|
||||
|
||||
|
||||
def test_allows_post_with_url_encoding(client):
|
||||
response = client.post(
|
||||
url_string(),
|
||||
urlencode(dict(query="{test}")),
|
||||
urlencode({"query": "{test}"}),
|
||||
"application/x-www-form-urlencoded",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == {"data": {"test": "Hello World"}}
|
||||
|
||||
|
||||
|
@ -247,7 +248,7 @@ def test_supports_post_json_query_with_string_variables(client):
|
|||
"application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == {"data": {"test": "Hello Dolly"}}
|
||||
|
||||
|
||||
|
@ -262,7 +263,7 @@ def test_batch_supports_post_json_query_with_string_variables(client):
|
|||
"application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == [
|
||||
{"id": 1, "data": {"test": "Hello Dolly"}, "status": 200}
|
||||
]
|
||||
|
@ -278,7 +279,7 @@ def test_supports_post_json_query_with_json_variables(client):
|
|||
"application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == {"data": {"test": "Hello Dolly"}}
|
||||
|
||||
|
||||
|
@ -293,7 +294,7 @@ def test_batch_supports_post_json_query_with_json_variables(client):
|
|||
"application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == [
|
||||
{"id": 1, "data": {"test": "Hello Dolly"}, "status": 200}
|
||||
]
|
||||
|
@ -303,15 +304,15 @@ def test_supports_post_url_encoded_query_with_string_variables(client):
|
|||
response = client.post(
|
||||
url_string(),
|
||||
urlencode(
|
||||
dict(
|
||||
query="query helloWho($who: String){ test(who: $who) }",
|
||||
variables=json.dumps({"who": "Dolly"}),
|
||||
)
|
||||
{
|
||||
"query": "query helloWho($who: String){ test(who: $who) }",
|
||||
"variables": json.dumps({"who": "Dolly"}),
|
||||
}
|
||||
),
|
||||
"application/x-www-form-urlencoded",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == {"data": {"test": "Hello Dolly"}}
|
||||
|
||||
|
||||
|
@ -322,18 +323,18 @@ def test_supports_post_json_quey_with_get_variable_values(client):
|
|||
"application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == {"data": {"test": "Hello Dolly"}}
|
||||
|
||||
|
||||
def test_post_url_encoded_query_with_get_variable_values(client):
|
||||
response = client.post(
|
||||
url_string(variables=json.dumps({"who": "Dolly"})),
|
||||
urlencode(dict(query="query helloWho($who: String){ test(who: $who) }")),
|
||||
urlencode({"query": "query helloWho($who: String){ test(who: $who) }"}),
|
||||
"application/x-www-form-urlencoded",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == {"data": {"test": "Hello Dolly"}}
|
||||
|
||||
|
||||
|
@ -344,7 +345,7 @@ def test_supports_post_raw_text_query_with_get_variable_values(client):
|
|||
"application/graphql",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == {"data": {"test": "Hello Dolly"}}
|
||||
|
||||
|
||||
|
@ -365,7 +366,7 @@ def test_allows_post_with_operation_name(client):
|
|||
"application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == {
|
||||
"data": {"test": "Hello World", "shared": "Hello Everyone"}
|
||||
}
|
||||
|
@ -389,7 +390,7 @@ def test_batch_allows_post_with_operation_name(client):
|
|||
"application/json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == [
|
||||
{
|
||||
"id": 1,
|
||||
|
@ -413,7 +414,7 @@ def test_allows_post_with_get_operation_name(client):
|
|||
"application/graphql",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == {
|
||||
"data": {"test": "Hello World", "shared": "Hello Everyone"}
|
||||
}
|
||||
|
@ -430,7 +431,7 @@ def test_inherited_class_with_attributes_works(client):
|
|||
|
||||
# Check graphiql works
|
||||
response = client.get(url_string(inherited_url), HTTP_ACCEPT="text/html")
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
|
||||
@pytest.mark.urls("graphene_django.tests.urls_pretty")
|
||||
|
@ -452,7 +453,7 @@ def test_supports_pretty_printing_by_request(client):
|
|||
|
||||
def test_handles_field_errors_caught_by_graphql(client):
|
||||
response = client.get(url_string(query="{thrower}"))
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == {
|
||||
"data": None,
|
||||
"errors": [
|
||||
|
@ -467,7 +468,7 @@ def test_handles_field_errors_caught_by_graphql(client):
|
|||
|
||||
def test_handles_syntax_errors_caught_by_graphql(client):
|
||||
response = client.get(url_string(query="syntaxerror"))
|
||||
assert response.status_code == 400
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response_json(response) == {
|
||||
"errors": [
|
||||
{
|
||||
|
@ -481,7 +482,7 @@ def test_handles_syntax_errors_caught_by_graphql(client):
|
|||
def test_handles_errors_caused_by_a_lack_of_query(client):
|
||||
response = client.get(url_string())
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response_json(response) == {
|
||||
"errors": [{"message": "Must provide query string."}]
|
||||
}
|
||||
|
@ -490,7 +491,7 @@ def test_handles_errors_caused_by_a_lack_of_query(client):
|
|||
def test_handles_not_expected_json_bodies(client):
|
||||
response = client.post(url_string(), "[]", "application/json")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response_json(response) == {
|
||||
"errors": [{"message": "The received data is not a valid JSON query."}]
|
||||
}
|
||||
|
@ -499,7 +500,7 @@ def test_handles_not_expected_json_bodies(client):
|
|||
def test_handles_invalid_json_bodies(client):
|
||||
response = client.post(url_string(), "[oh}", "application/json")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response_json(response) == {
|
||||
"errors": [{"message": "POST body sent invalid JSON."}]
|
||||
}
|
||||
|
@ -511,17 +512,17 @@ def test_handles_django_request_error(client, monkeypatch):
|
|||
|
||||
monkeypatch.setattr("django.http.request.HttpRequest.read", mocked_read)
|
||||
|
||||
valid_json = json.dumps(dict(foo="bar"))
|
||||
valid_json = json.dumps({"foo": "bar"})
|
||||
response = client.post(url_string(), valid_json, "application/json")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response_json(response) == {"errors": [{"message": "foo-bar"}]}
|
||||
|
||||
|
||||
def test_handles_incomplete_json_bodies(client):
|
||||
response = client.post(url_string(), '{"query":', "application/json")
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response_json(response) == {
|
||||
"errors": [{"message": "POST body sent invalid JSON."}]
|
||||
}
|
||||
|
@ -533,7 +534,7 @@ def test_handles_plain_post_text(client):
|
|||
"query helloWho($who: String){ test(who: $who) }",
|
||||
"text/plain",
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response_json(response) == {
|
||||
"errors": [{"message": "Must provide query string."}]
|
||||
}
|
||||
|
@ -545,7 +546,7 @@ def test_handles_poorly_formed_variables(client):
|
|||
query="query helloWho($who: String){ test(who: $who) }", variables="who:You"
|
||||
)
|
||||
)
|
||||
assert response.status_code == 400
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
assert response_json(response) == {
|
||||
"errors": [{"message": "Variables are invalid JSON."}]
|
||||
}
|
||||
|
@ -553,7 +554,7 @@ def test_handles_poorly_formed_variables(client):
|
|||
|
||||
def test_handles_unsupported_http_methods(client):
|
||||
response = client.put(url_string(query="{test}"))
|
||||
assert response.status_code == 405
|
||||
assert response.status_code == HTTPStatus.METHOD_NOT_ALLOWED
|
||||
assert response["Allow"] == "GET, POST"
|
||||
assert response_json(response) == {
|
||||
"errors": [{"message": "GraphQL only supports GET and POST requests."}]
|
||||
|
@ -563,7 +564,7 @@ def test_handles_unsupported_http_methods(client):
|
|||
def test_passes_request_into_context_request(client):
|
||||
response = client.get(url_string(query="{request}", q="testing"))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
assert response_json(response) == {"data": {"request": "testing"}}
|
||||
|
||||
|
||||
|
@ -827,3 +828,97 @@ def test_query_errors_atomic_request(set_rollback_mock, client):
|
|||
def test_query_errors_non_atomic(set_rollback_mock, client):
|
||||
client.get(url_string(query="force error"))
|
||||
set_rollback_mock.assert_not_called()
|
||||
|
||||
|
||||
VALIDATION_URLS = [
|
||||
"/graphql/validation/",
|
||||
"/graphql/validation/alternative/",
|
||||
"/graphql/validation/inherited/",
|
||||
]
|
||||
|
||||
QUERY_WITH_TWO_INTROSPECTIONS = """
|
||||
query Instrospection {
|
||||
queryType: __schema {
|
||||
queryType {name}
|
||||
}
|
||||
mutationType: __schema {
|
||||
mutationType {name}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
N_INTROSPECTIONS = 2
|
||||
|
||||
INTROSPECTION_DISALLOWED_ERROR_MESSAGE = "introspection is disabled"
|
||||
MAX_VALIDATION_ERRORS_EXCEEDED_MESSAGE = "too many validation errors"
|
||||
|
||||
|
||||
@pytest.mark.urls("graphene_django.tests.urls_validation")
|
||||
def test_allow_introspection(client):
|
||||
response = client.post(
|
||||
url_string("/graphql/", query="{__schema {queryType {name}}}")
|
||||
)
|
||||
assert response.status_code == HTTPStatus.OK
|
||||
|
||||
assert response_json(response) == {
|
||||
"data": {"__schema": {"queryType": {"name": "QueryRoot"}}}
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", VALIDATION_URLS)
|
||||
@pytest.mark.urls("graphene_django.tests.urls_validation")
|
||||
def test_validation_disallow_introspection(client, url):
|
||||
response = client.post(url_string(url, query="{__schema {queryType {name}}}"))
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
json_response = response_json(response)
|
||||
assert "data" not in json_response
|
||||
assert "errors" in json_response
|
||||
assert len(json_response["errors"]) == 1
|
||||
|
||||
error_message = json_response["errors"][0]["message"]
|
||||
assert INTROSPECTION_DISALLOWED_ERROR_MESSAGE in error_message
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", VALIDATION_URLS)
|
||||
@pytest.mark.urls("graphene_django.tests.urls_validation")
|
||||
@patch(
|
||||
"graphene_django.settings.graphene_settings.MAX_VALIDATION_ERRORS", N_INTROSPECTIONS
|
||||
)
|
||||
def test_within_max_validation_errors(client, url):
|
||||
response = client.post(url_string(url, query=QUERY_WITH_TWO_INTROSPECTIONS))
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
json_response = response_json(response)
|
||||
assert "data" not in json_response
|
||||
assert "errors" in json_response
|
||||
assert len(json_response["errors"]) == N_INTROSPECTIONS
|
||||
|
||||
error_messages = [error["message"].lower() for error in json_response["errors"]]
|
||||
|
||||
n_introspection_error_messages = sum(
|
||||
INTROSPECTION_DISALLOWED_ERROR_MESSAGE in msg for msg in error_messages
|
||||
)
|
||||
assert n_introspection_error_messages == N_INTROSPECTIONS
|
||||
|
||||
assert all(
|
||||
MAX_VALIDATION_ERRORS_EXCEEDED_MESSAGE not in msg for msg in error_messages
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("url", VALIDATION_URLS)
|
||||
@pytest.mark.urls("graphene_django.tests.urls_validation")
|
||||
@patch("graphene_django.settings.graphene_settings.MAX_VALIDATION_ERRORS", 1)
|
||||
def test_exceeds_max_validation_errors(client, url):
|
||||
response = client.post(url_string(url, query=QUERY_WITH_TWO_INTROSPECTIONS))
|
||||
|
||||
assert response.status_code == HTTPStatus.BAD_REQUEST
|
||||
|
||||
json_response = response_json(response)
|
||||
assert "data" not in json_response
|
||||
assert "errors" in json_response
|
||||
|
||||
error_messages = (error["message"].lower() for error in json_response["errors"])
|
||||
assert any(MAX_VALIDATION_ERRORS_EXCEEDED_MESSAGE in msg for msg in error_messages)
|
||||
|
|
26
graphene_django/tests/urls_validation.py
Normal file
26
graphene_django/tests/urls_validation.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
from django.urls import path
|
||||
|
||||
from graphene.validation import DisableIntrospection
|
||||
|
||||
from ..views import GraphQLView
|
||||
from .schema_view import schema
|
||||
|
||||
|
||||
class View(GraphQLView):
|
||||
schema = schema
|
||||
|
||||
|
||||
class NoIntrospectionView(View):
|
||||
validation_rules = (DisableIntrospection,)
|
||||
|
||||
|
||||
class NoIntrospectionViewInherited(NoIntrospectionView):
|
||||
pass
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path("graphql/", View.as_view()),
|
||||
path("graphql/validation/", View.as_view(validation_rules=(DisableIntrospection,))),
|
||||
path("graphql/validation/alternative/", NoIntrospectionView.as_view()),
|
||||
path("graphql/validation/inherited/", NoIntrospectionViewInherited.as_view()),
|
||||
]
|
|
@ -1,9 +1,10 @@
|
|||
import warnings
|
||||
from collections import OrderedDict
|
||||
from typing import Type
|
||||
from typing import Type # noqa: F401
|
||||
|
||||
from django.db.models import Model # noqa: F401
|
||||
|
||||
import graphene
|
||||
from django.db.models import Model
|
||||
from graphene.relay import Connection, Node
|
||||
from graphene.types.objecttype import ObjectType, ObjectTypeOptions
|
||||
from graphene.types.utils import yank_fields_from_attrs
|
||||
|
@ -22,7 +23,7 @@ ALL_FIELDS = "__all__"
|
|||
|
||||
|
||||
def construct_fields(
|
||||
model, registry, only_fields, exclude_fields, convert_choices_to_enum
|
||||
model, registry, only_fields, exclude_fields, convert_choices_to_enum=None
|
||||
):
|
||||
_model_fields = get_model_fields(model)
|
||||
|
||||
|
@ -46,7 +47,7 @@ def construct_fields(
|
|||
continue
|
||||
|
||||
_convert_choices_to_enum = convert_choices_to_enum
|
||||
if not isinstance(_convert_choices_to_enum, bool):
|
||||
if isinstance(_convert_choices_to_enum, list):
|
||||
# then `convert_choices_to_enum` is a list of field names to convert
|
||||
if name in _convert_choices_to_enum:
|
||||
_convert_choices_to_enum = True
|
||||
|
@ -101,10 +102,8 @@ def validate_fields(type_, model, fields, only_fields, exclude_fields):
|
|||
if name in all_field_names:
|
||||
# Field is a custom field
|
||||
warnings.warn(
|
||||
(
|
||||
'Excluding the custom field "{field_name}" on DjangoObjectType "{type_}" has no effect. '
|
||||
'Either remove the custom field or remove the field from the "exclude" list.'
|
||||
).format(field_name=name, type_=type_)
|
||||
f'Excluding the custom field "{name}" on DjangoObjectType "{type_}" has no effect. '
|
||||
'Either remove the custom field or remove the field from the "exclude" list.'
|
||||
)
|
||||
else:
|
||||
if not hasattr(model, name):
|
||||
|
@ -147,9 +146,9 @@ class DjangoObjectType(ObjectType):
|
|||
connection_class=None,
|
||||
use_connection=None,
|
||||
interfaces=(),
|
||||
convert_choices_to_enum=True,
|
||||
convert_choices_to_enum=None,
|
||||
_meta=None,
|
||||
**options
|
||||
**options,
|
||||
):
|
||||
assert is_valid_django_model(model), (
|
||||
'You need to pass a valid Django Model in {}.Meta, received "{}".'
|
||||
|
@ -159,9 +158,9 @@ class DjangoObjectType(ObjectType):
|
|||
registry = get_global_registry()
|
||||
|
||||
assert isinstance(registry, Registry), (
|
||||
"The attribute registry in {} needs to be an instance of "
|
||||
'Registry, received "{}".'
|
||||
).format(cls.__name__, registry)
|
||||
f"The attribute registry in {cls.__name__} needs to be an instance of "
|
||||
f'Registry, received "{registry}".'
|
||||
)
|
||||
|
||||
if filter_fields and filterset_class:
|
||||
raise Exception("Can't set both filter_fields and filterset_class")
|
||||
|
@ -174,7 +173,7 @@ class DjangoObjectType(ObjectType):
|
|||
|
||||
assert not (fields and exclude), (
|
||||
"Cannot set both 'fields' and 'exclude' options on "
|
||||
"DjangoObjectType {class_name}.".format(class_name=cls.__name__)
|
||||
f"DjangoObjectType {cls.__name__}."
|
||||
)
|
||||
|
||||
# Alias only_fields -> fields
|
||||
|
@ -213,8 +212,8 @@ class DjangoObjectType(ObjectType):
|
|||
warnings.warn(
|
||||
"Creating a DjangoObjectType without either the `fields` "
|
||||
"or the `exclude` option is deprecated. Add an explicit `fields "
|
||||
"= '__all__'` option on DjangoObjectType {class_name} to use all "
|
||||
"fields".format(class_name=cls.__name__),
|
||||
f"= '__all__'` option on DjangoObjectType {cls.__name__} to use all "
|
||||
"fields",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
@ -239,9 +238,9 @@ class DjangoObjectType(ObjectType):
|
|||
)
|
||||
|
||||
if connection is not None:
|
||||
assert issubclass(connection, Connection), (
|
||||
"The connection must be a Connection. Received {}"
|
||||
).format(connection.__name__)
|
||||
assert issubclass(
|
||||
connection, Connection
|
||||
), f"The connection must be a Connection. Received {connection.__name__}"
|
||||
|
||||
if not _meta:
|
||||
_meta = DjangoObjectTypeOptions(cls)
|
||||
|
@ -252,6 +251,7 @@ class DjangoObjectType(ObjectType):
|
|||
_meta.filterset_class = filterset_class
|
||||
_meta.fields = django_fields
|
||||
_meta.connection = connection
|
||||
_meta.convert_choices_to_enum = convert_choices_to_enum
|
||||
|
||||
super().__init_subclass_with_meta__(
|
||||
_meta=_meta, interfaces=interfaces, **options
|
||||
|
@ -271,7 +271,7 @@ class DjangoObjectType(ObjectType):
|
|||
if isinstance(root, cls):
|
||||
return True
|
||||
if not is_valid_django_model(root.__class__):
|
||||
raise Exception(('Received incompatible instance "{}".').format(root))
|
||||
raise Exception(f'Received incompatible instance "{root}".')
|
||||
|
||||
if cls._meta.model._meta.proxy:
|
||||
model = root._meta.model
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from .testing import GraphQLTestCase
|
||||
from .utils import (
|
||||
DJANGO_FILTER_INSTALLED,
|
||||
bypass_get_queryset,
|
||||
camelize,
|
||||
get_model_fields,
|
||||
get_reverse_fields,
|
||||
|
@ -16,4 +17,5 @@ __all__ = [
|
|||
"camelize",
|
||||
"is_valid_django_model",
|
||||
"GraphQLTestCase",
|
||||
"bypass_get_queryset",
|
||||
]
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import re
|
||||
|
||||
from text_unidecode import unidecode
|
||||
|
||||
|
||||
|
|
|
@ -4,6 +4,7 @@ import warnings
|
|||
from django.test import Client, TestCase, TransactionTestCase
|
||||
|
||||
from graphene_django.settings import graphene_settings
|
||||
from graphene_django.utils.utils import _DJANGO_VERSION_AT_LEAST_4_2
|
||||
|
||||
DEFAULT_GRAPHQL_URL = "/graphql"
|
||||
|
||||
|
@ -55,8 +56,14 @@ def graphql_query(
|
|||
else:
|
||||
body["variables"] = {"input": input_data}
|
||||
if headers:
|
||||
header_params = (
|
||||
{"headers": headers} if _DJANGO_VERSION_AT_LEAST_4_2 else headers
|
||||
)
|
||||
resp = client.post(
|
||||
graphql_url, json.dumps(body), content_type="application/json", **headers
|
||||
graphql_url,
|
||||
json.dumps(body),
|
||||
content_type="application/json",
|
||||
**header_params,
|
||||
)
|
||||
else:
|
||||
resp = client.post(
|
||||
|
|
|
@ -1,10 +1,10 @@
|
|||
import pytest
|
||||
|
||||
from .. import GraphQLTestCase
|
||||
from ...tests.test_types import with_local_registry
|
||||
from ...settings import graphene_settings
|
||||
from django.test import Client
|
||||
|
||||
from ...settings import graphene_settings
|
||||
from ...tests.test_types import with_local_registry
|
||||
from .. import GraphQLTestCase
|
||||
|
||||
|
||||
@with_local_registry
|
||||
def test_graphql_test_case_deprecated_client_getter():
|
||||
|
@ -23,7 +23,7 @@ def test_graphql_test_case_deprecated_client_getter():
|
|||
tc.setUpClass()
|
||||
|
||||
with pytest.warns(PendingDeprecationWarning):
|
||||
tc._client
|
||||
tc._client # noqa: B018
|
||||
|
||||
|
||||
@with_local_registry
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import inspect
|
||||
|
||||
import django
|
||||
from django.db import connection, models, transaction
|
||||
from django.db.models.manager import Manager
|
||||
from django.utils.encoding import force_str
|
||||
|
@ -37,18 +38,52 @@ def camelize(data):
|
|||
return data
|
||||
|
||||
|
||||
def get_reverse_fields(model, local_field_names):
|
||||
for name, attr in model.__dict__.items():
|
||||
# Don't duplicate any local fields
|
||||
if name in local_field_names:
|
||||
continue
|
||||
def _get_model_ancestry(model):
|
||||
model_ancestry = [model]
|
||||
|
||||
# "rel" for FK and M2M relations and "related" for O2O Relations
|
||||
related = getattr(attr, "rel", None) or getattr(attr, "related", None)
|
||||
if isinstance(related, models.ManyToOneRel):
|
||||
yield (name, related)
|
||||
elif isinstance(related, models.ManyToManyRel) and not related.symmetrical:
|
||||
yield (name, related)
|
||||
for base in model.__bases__:
|
||||
if is_valid_django_model(base) and getattr(base, "_meta", False):
|
||||
model_ancestry.append(base)
|
||||
return model_ancestry
|
||||
|
||||
|
||||
def get_reverse_fields(model, local_field_names):
|
||||
"""
|
||||
Searches through the model's ancestry and gets reverse relationships the models
|
||||
Yields a tuple of (field.name, field)
|
||||
"""
|
||||
model_ancestry = _get_model_ancestry(model)
|
||||
|
||||
for _model in model_ancestry:
|
||||
for name, attr in _model.__dict__.items():
|
||||
# Don't duplicate any local fields
|
||||
if name in local_field_names:
|
||||
continue
|
||||
|
||||
# "rel" for FK and M2M relations and "related" for O2O Relations
|
||||
related = getattr(attr, "rel", None) or getattr(attr, "related", None)
|
||||
if isinstance(related, models.ManyToOneRel):
|
||||
yield (name, related)
|
||||
elif isinstance(related, models.ManyToManyRel) and not related.symmetrical:
|
||||
yield (name, related)
|
||||
|
||||
|
||||
def get_local_fields(model):
|
||||
"""
|
||||
Searches through the model's ancestry and gets the fields on the models
|
||||
Returns a dict of {field.name: field}
|
||||
"""
|
||||
model_ancestry = _get_model_ancestry(model)
|
||||
|
||||
local_fields_dict = {}
|
||||
for _model in model_ancestry:
|
||||
for field in sorted(
|
||||
list(_model._meta.fields) + list(_model._meta.local_many_to_many)
|
||||
):
|
||||
if field.name not in local_fields_dict:
|
||||
local_fields_dict[field.name] = field
|
||||
|
||||
return list(local_fields_dict.items())
|
||||
|
||||
|
||||
def maybe_queryset(value):
|
||||
|
@ -58,17 +93,14 @@ def maybe_queryset(value):
|
|||
|
||||
|
||||
def get_model_fields(model):
|
||||
local_fields = [
|
||||
(field.name, field)
|
||||
for field in sorted(
|
||||
list(model._meta.fields) + list(model._meta.local_many_to_many)
|
||||
)
|
||||
]
|
||||
|
||||
# Make sure we don't duplicate local fields with "reverse" version
|
||||
local_field_names = [field[0] for field in local_fields]
|
||||
"""
|
||||
Gets all the fields and relationships on the Django model and its ancestry.
|
||||
Prioritizes local fields and relationships over the reverse relationships of the same name
|
||||
Returns a tuple of (field.name, field)
|
||||
"""
|
||||
local_fields = get_local_fields(model)
|
||||
local_field_names = {field[0] for field in local_fields}
|
||||
reverse_fields = get_reverse_fields(model, local_field_names)
|
||||
|
||||
all_fields = local_fields + list(reverse_fields)
|
||||
|
||||
return all_fields
|
||||
|
@ -79,24 +111,7 @@ def is_valid_django_model(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"
|
||||
)
|
||||
from functools import singledispatch
|
||||
|
||||
return singledispatch
|
||||
|
||||
|
@ -105,3 +120,17 @@ def set_rollback():
|
|||
atomic_requests = connection.settings_dict.get("ATOMIC_REQUESTS", False)
|
||||
if atomic_requests and connection.in_atomic_block:
|
||||
transaction.set_rollback(True)
|
||||
|
||||
|
||||
def bypass_get_queryset(resolver):
|
||||
"""
|
||||
Adds a bypass_get_queryset attribute to the resolver, which is used to
|
||||
bypass any custom get_queryset method of the DjangoObjectType.
|
||||
"""
|
||||
resolver._bypass_get_queryset = True
|
||||
return resolver
|
||||
|
||||
|
||||
_DJANGO_VERSION_AT_LEAST_4_2 = django.VERSION[0] > 4 or (
|
||||
django.VERSION[0] >= 4 and django.VERSION[1] >= 2
|
||||
)
|
||||
|
|
|
@ -9,13 +9,19 @@ from django.shortcuts import render
|
|||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||
from django.views.generic import View
|
||||
from graphql import OperationType, get_operation_ast, parse, validate
|
||||
from graphql import (
|
||||
ExecutionResult,
|
||||
OperationType,
|
||||
execute,
|
||||
get_operation_ast,
|
||||
parse,
|
||||
validate_schema,
|
||||
)
|
||||
from graphql.error import GraphQLError
|
||||
from graphql.execution import ExecutionResult
|
||||
from graphql.execution.middleware import MiddlewareManager
|
||||
from graphql.validation import validate
|
||||
|
||||
from graphene import Schema
|
||||
from graphql.execution.middleware import MiddlewareManager
|
||||
|
||||
from graphene_django.constants import MUTATION_ERRORS_FLAG
|
||||
from graphene_django.utils.utils import set_rollback
|
||||
|
||||
|
@ -40,9 +46,9 @@ def get_accepted_content_types(request):
|
|||
|
||||
raw_content_types = request.META.get("HTTP_ACCEPT", "*/*").split(",")
|
||||
qualified_content_types = map(qualify, raw_content_types)
|
||||
return list(
|
||||
return [
|
||||
x[0] for x in sorted(qualified_content_types, key=lambda x: x[1], reverse=True)
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def instantiate_middleware(middlewares):
|
||||
|
@ -66,18 +72,21 @@ class GraphQLView(View):
|
|||
react_dom_sri = "sha256-nbMykgB6tsOFJ7OdVmPpdqMFVk4ZsqWocT6issAPUF0="
|
||||
|
||||
# The GraphiQL React app.
|
||||
graphiql_version = "2.4.1" # "1.0.3"
|
||||
graphiql_sri = "sha256-s+f7CFAPSUIygFnRC2nfoiEKd3liCUy+snSdYFAoLUc=" # "sha256-VR4buIDY9ZXSyCNFHFNik6uSe0MhigCzgN4u7moCOTk="
|
||||
graphiql_css_sri = "sha256-88yn8FJMyGboGs4Bj+Pbb3kWOWXo7jmb+XCRHE+282k=" # "sha256-LwqxjyZgqXDYbpxQJ5zLQeNcf7WVNSJ+r8yp2rnWE/E="
|
||||
graphiql_version = "2.4.7"
|
||||
graphiql_sri = "sha256-n/LKaELupC1H/PU6joz+ybeRJHT2xCdekEt6OYMOOZU="
|
||||
graphiql_css_sri = "sha256-OsbM+LQHcnFHi0iH7AUKueZvDcEBoy/z4hJ7jx1cpsM="
|
||||
|
||||
# The websocket transport library for subscriptions.
|
||||
subscriptions_transport_ws_version = "5.12.1"
|
||||
subscriptions_transport_ws_version = "5.13.1"
|
||||
subscriptions_transport_ws_sri = (
|
||||
"sha256-EZhvg6ANJrBsgLvLAa0uuHNLepLJVCFYS+xlb5U/bqw="
|
||||
)
|
||||
|
||||
graphiql_plugin_explorer_version = "0.1.15"
|
||||
graphiql_plugin_explorer_sri = "sha256-3hUuhBXdXlfCj6RTeEkJFtEh/kUG+TCDASFpFPLrzvE="
|
||||
graphiql_plugin_explorer_css_sri = (
|
||||
"sha256-fA0LPUlukMNR6L4SPSeFqDTYav8QdWjQ2nr559Zln1U="
|
||||
)
|
||||
|
||||
schema = None
|
||||
graphiql = False
|
||||
|
@ -87,6 +96,7 @@ class GraphQLView(View):
|
|||
batch = False
|
||||
subscription_path = None
|
||||
execution_context_class = None
|
||||
validation_rules = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
|
@ -98,6 +108,7 @@ class GraphQLView(View):
|
|||
batch=False,
|
||||
subscription_path=None,
|
||||
execution_context_class=None,
|
||||
validation_rules=None,
|
||||
):
|
||||
if not schema:
|
||||
schema = graphene_settings.SCHEMA
|
||||
|
@ -105,17 +116,19 @@ class GraphQLView(View):
|
|||
if middleware is None:
|
||||
middleware = graphene_settings.MIDDLEWARE
|
||||
|
||||
self.schema = self.schema or schema
|
||||
self.schema = schema or self.schema
|
||||
if middleware is not None:
|
||||
if isinstance(middleware, MiddlewareManager):
|
||||
self.middleware = middleware
|
||||
else:
|
||||
self.middleware = list(instantiate_middleware(middleware))
|
||||
self.root_value = root_value
|
||||
self.pretty = self.pretty or pretty
|
||||
self.graphiql = self.graphiql or graphiql
|
||||
self.batch = self.batch or batch
|
||||
self.execution_context_class = execution_context_class
|
||||
self.pretty = pretty or self.pretty
|
||||
self.graphiql = graphiql or self.graphiql
|
||||
self.batch = batch or self.batch
|
||||
self.execution_context_class = (
|
||||
execution_context_class or self.execution_context_class
|
||||
)
|
||||
if subscription_path is None:
|
||||
self.subscription_path = graphene_settings.SUBSCRIPTION_PATH
|
||||
|
||||
|
@ -124,6 +137,8 @@ class GraphQLView(View):
|
|||
), "A Schema is required to be provided to GraphQLView."
|
||||
assert not all((graphiql, batch)), "Use either graphiql or batch processing"
|
||||
|
||||
self.validation_rules = validation_rules or self.validation_rules
|
||||
|
||||
# noinspection PyUnusedLocal
|
||||
def get_root_value(self, request):
|
||||
return self.root_value
|
||||
|
@ -163,11 +178,13 @@ class GraphQLView(View):
|
|||
subscriptions_transport_ws_sri=self.subscriptions_transport_ws_sri,
|
||||
graphiql_plugin_explorer_version=self.graphiql_plugin_explorer_version,
|
||||
graphiql_plugin_explorer_sri=self.graphiql_plugin_explorer_sri,
|
||||
graphiql_plugin_explorer_css_sri=self.graphiql_plugin_explorer_css_sri,
|
||||
# The SUBSCRIPTION_PATH setting.
|
||||
subscription_path=self.subscription_path,
|
||||
# GraphiQL headers tab,
|
||||
graphiql_header_editor_enabled=graphene_settings.GRAPHIQL_HEADER_EDITOR_ENABLED,
|
||||
graphiql_should_persist_headers=graphene_settings.GRAPHIQL_SHOULD_PERSIST_HEADERS,
|
||||
graphiql_input_value_deprecation=graphene_settings.GRAPHIQL_INPUT_VALUE_DEPRECATION,
|
||||
)
|
||||
|
||||
if self.batch:
|
||||
|
@ -289,48 +306,61 @@ class GraphQLView(View):
|
|||
return None
|
||||
raise HttpError(HttpResponseBadRequest("Must provide query string."))
|
||||
|
||||
schema = self.schema.graphql_schema
|
||||
|
||||
schema_validation_errors = validate_schema(schema)
|
||||
if schema_validation_errors:
|
||||
return ExecutionResult(data=None, errors=schema_validation_errors)
|
||||
|
||||
try:
|
||||
document = parse(query)
|
||||
except Exception as e:
|
||||
return ExecutionResult(errors=[e])
|
||||
|
||||
if request.method.lower() == "get":
|
||||
operation_ast = get_operation_ast(document, operation_name)
|
||||
if operation_ast and operation_ast.operation != OperationType.QUERY:
|
||||
if show_graphiql:
|
||||
return None
|
||||
operation_ast = get_operation_ast(document, operation_name)
|
||||
|
||||
raise HttpError(
|
||||
HttpResponseNotAllowed(
|
||||
["POST"],
|
||||
"Can only perform a {} operation from a POST request.".format(
|
||||
operation_ast.operation.value
|
||||
),
|
||||
)
|
||||
if (
|
||||
request.method.lower() == "get"
|
||||
and operation_ast is not None
|
||||
and operation_ast.operation != OperationType.QUERY
|
||||
):
|
||||
if show_graphiql:
|
||||
return None
|
||||
|
||||
raise HttpError(
|
||||
HttpResponseNotAllowed(
|
||||
["POST"],
|
||||
"Can only perform a {} operation from a POST request.".format(
|
||||
operation_ast.operation.value
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
validation_errors = validate(
|
||||
schema,
|
||||
document,
|
||||
self.validation_rules,
|
||||
graphene_settings.MAX_VALIDATION_ERRORS,
|
||||
)
|
||||
|
||||
validation_errors = validate(self.schema.graphql_schema, document)
|
||||
if validation_errors:
|
||||
return ExecutionResult(data=None, errors=validation_errors)
|
||||
|
||||
try:
|
||||
extra_options = {}
|
||||
if self.execution_context_class:
|
||||
extra_options["execution_context_class"] = self.execution_context_class
|
||||
|
||||
options = {
|
||||
"source": query,
|
||||
execute_options = {
|
||||
"root_value": self.get_root_value(request),
|
||||
"context_value": self.get_context(request),
|
||||
"variable_values": variables,
|
||||
"operation_name": operation_name,
|
||||
"context_value": self.get_context(request),
|
||||
"middleware": self.get_middleware(request),
|
||||
}
|
||||
options.update(extra_options)
|
||||
if self.execution_context_class:
|
||||
execute_options[
|
||||
"execution_context_class"
|
||||
] = self.execution_context_class
|
||||
|
||||
operation_ast = get_operation_ast(document, operation_name)
|
||||
if (
|
||||
operation_ast
|
||||
operation_ast is not None
|
||||
and operation_ast.operation == OperationType.MUTATION
|
||||
and (
|
||||
graphene_settings.ATOMIC_MUTATIONS is True
|
||||
|
@ -338,12 +368,12 @@ class GraphQLView(View):
|
|||
)
|
||||
):
|
||||
with transaction.atomic():
|
||||
result = self.schema.execute(**options)
|
||||
result = execute(schema, document, **execute_options)
|
||||
if getattr(request, MUTATION_ERRORS_FLAG, False) is True:
|
||||
transaction.set_rollback(True)
|
||||
return result
|
||||
|
||||
return self.schema.execute(**options)
|
||||
return execute(schema, document, **execute_options)
|
||||
except Exception as e:
|
||||
return ExecutionResult(errors=[e])
|
||||
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user