mirror of
https://github.com/graphql-python/graphene-django.git
synced 2025-04-15 14:42:06 +03:00
Compare commits
181 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
c52cf2b045 | ||
|
e69e4a0399 | ||
|
97deb761e9 | ||
|
8d4a64a40d | ||
|
269225085d | ||
|
28c71c58f7 | ||
|
6f21dc7a94 | ||
|
ea45de02ad | ||
|
eac113e136 | ||
|
d69c90550f | ||
|
3f813d4679 | ||
|
45c2aa09b5 | ||
|
ac09cd2967 | ||
|
54372b41d5 | ||
|
96c09ac439 | ||
|
b85177cebf | ||
|
4d0484f312 | ||
|
c416a2b0f5 | ||
|
feb7252b8a | ||
|
3a64994e52 | ||
|
db2d40ec94 | ||
|
62126dd467 | ||
|
e735f5dbdb | ||
|
36cf100e8b | ||
|
e8f36b018d | ||
|
83d3d27f14 | ||
|
ee7560f629 | ||
|
67def2e074 | ||
|
e49a01c189 | ||
|
0473f1a9a3 | ||
|
720db1f987 | ||
|
4ac3f3f42d | ||
|
ee7598e71a | ||
|
05d7fb5396 | ||
|
79b4a23ae0 | ||
|
db34d2e815 | ||
|
9a773b9d7b | ||
|
45a732f1db | ||
|
5eb5fe294a | ||
|
5d7a04fce9 | ||
|
3172710d12 | ||
|
b1abebdb97 | ||
|
0de35ca3b0 | ||
|
2fafa881a8 | ||
|
cd43022283 | ||
|
3f061a0c50 | ||
|
e950164c8e | ||
|
2358bd30a4 | ||
|
3e7a16af73 | ||
|
8fa8aea3c0 | ||
|
c925a32dc3 | ||
|
8934393909 | ||
|
520ddeabf6 | ||
|
38709d8396 | ||
|
63fd98393f | ||
|
4e5acd4702 | ||
|
ebf49431e9 | ||
|
b75904d4c8 | ||
|
7fe661d423 | ||
|
be17278b49 | ||
|
a6596273cf | ||
|
388ca41d64 | ||
|
72a3700856 | ||
|
09f9b6d2f1 | ||
|
6f13d28b6e | ||
|
ce7492b5ae | ||
|
52f992183f | ||
|
8540a9332c | ||
|
af8888f58e | ||
|
c1a22bfd91 | ||
|
95a0642818 | ||
|
a8ceca77ed | ||
|
20a6cecc4c | ||
|
9796e93fc7 | ||
|
f67c5dbc8c | ||
|
34cc86063b | ||
|
a335042dbe | ||
|
df3c0bf75b | ||
|
7e1a1d1fb8 | ||
|
3283d0b1be | ||
|
1d814c54c4 | ||
|
0beb3385df | ||
|
3b41aaf7bf | ||
|
d18cab8aa4 | ||
|
daa0ab046b | ||
|
a000d58514 | ||
|
86c5309c45 | ||
|
4517e32224 | ||
|
f24cbd5148 | ||
|
ed7c995d8c | ||
|
bb03306075 | ||
|
c697e5c8c1 | ||
|
a78114ada3 | ||
|
07940aa5f5 | ||
|
9a60589732 | ||
|
0b2cc4ecb2 | ||
|
97442f9cee | ||
|
60b3032014 | ||
|
05d3df92e7 | ||
|
541caa117e | ||
|
0f40da7b31 | ||
|
5d81ba04f9 | ||
|
b2f83eb277 | ||
|
56892d7f4b | ||
|
4f315c365d | ||
|
a53ded611b | ||
|
37848fa2df | ||
|
3473fe025e | ||
|
42a40b4df0 | ||
|
8ae576394e | ||
|
2aeb86ba3b | ||
|
5f1731dca3 | ||
|
f6ec0689c1 | ||
|
0bb9f1ca60 | ||
|
bf8fd7696b | ||
|
775644b536 | ||
|
e1a7d19833 | ||
|
5d5d7f1815 | ||
|
32667b5407 | ||
|
ef9d67302e | ||
|
1e4b03b975 | ||
|
e7f7d8da07 | ||
|
623d0f219e | ||
|
608af578d4 | ||
|
26a851a523 | ||
|
762eaabd04 | ||
|
80ea51fc3b | ||
|
3cf940d0c8 | ||
|
3058118e8f | ||
|
594ca6e25e | ||
|
573d38e13e | ||
|
212524fd8c | ||
|
fe66b48d38 | ||
|
e9f25ecf2d | ||
|
6046a710c8 | ||
|
ea593b673f | ||
|
5cee41407c | ||
|
2d4ca0ac7b | ||
|
5ce4553244 | ||
|
007768b454 | ||
|
beb2e4aae3 | ||
|
52880166bd | ||
|
4573d3db53 | ||
|
c3404a9793 | ||
|
d9ab8acf26 | ||
|
5dea6ffa41 | ||
|
bcc7f85dad | ||
|
10e48c27b7 | ||
|
ea84827ab8 | ||
|
fdeadf5ce5 | ||
|
8324d47999 | ||
|
c049ab7470 | ||
|
48ed516b5e | ||
|
b1b57d815a | ||
|
53023423b6 | ||
|
33c6a54414 | ||
|
da9f41c295 | ||
|
62f95f2858 | ||
|
6cfcddac5a | ||
|
965ebdee13 | ||
|
88f6ec458c | ||
|
08d0cce55a | ||
|
f8b88fdc9a | ||
|
1f752b6cad | ||
|
17146f9b01 | ||
|
85976ffb1f | ||
|
26c4c48abc | ||
|
d9c187ffc2 | ||
|
b320b94a73 | ||
|
fb90cb78b3 | ||
|
680bf72871 | ||
|
c79b12b5a3 | ||
|
9b41472922 | ||
|
07c51092e1 | ||
|
10d22de98e | ||
|
77b9832606 | ||
|
82d8dbc893 | ||
|
f33223daa7 | ||
|
9bb0554c94 | ||
|
657208054a | ||
|
dd0d6ef28f |
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
6
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
@ -27,8 +27,8 @@ a github repo, https://repl.it or similar (you can use this template as a starti
|
|||
|
||||
|
||||
* **Please tell us about your environment:**
|
||||
|
||||
- Version:
|
||||
- Platform:
|
||||
|
||||
- Version:
|
||||
- Platform:
|
||||
|
||||
* **Other information** (e.g. detailed explanation, stacktraces, related issues, suggestions how to fix, links for us to have context, eg. stackoverflow)
|
||||
|
|
17
.github/workflows/deploy.yml
vendored
17
.github/workflows/deploy.yml
vendored
|
@ -6,21 +6,26 @@ 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@v1
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v1
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.8
|
||||
python-version: '3.11'
|
||||
- name: Build wheel and source tarball
|
||||
run: |
|
||||
pip install wheel
|
||||
python setup.py sdist bdist_wheel
|
||||
- name: Publish a Python distribution to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@v1.1.0
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
user: __token__
|
||||
password: ${{ secrets.pypi_password }}
|
||||
|
|
18
.github/workflows/lint.yml
vendored
18
.github/workflows/lint.yml
vendored
|
@ -1,22 +1,26 @@
|
|||
name: Lint
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- name: Set up Python 3.8
|
||||
uses: actions/setup-python@v1
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python 3.11
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: 3.8
|
||||
python-version: '3.11'
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install tox
|
||||
- name: Run lint 💅
|
||||
- name: Run pre-commit 💅
|
||||
run: tox
|
||||
env:
|
||||
TOXENV: flake8
|
||||
TOXENV: pre-commit
|
||||
|
|
32
.github/workflows/tests.yml
vendored
32
.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,16 +12,25 @@ jobs:
|
|||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
django: ["1.11", "2.2", "3.0", "3.1"]
|
||||
python-version: ["3.6", "3.7", "3.8"]
|
||||
include:
|
||||
- django: "1.11"
|
||||
python-version: "2.7"
|
||||
|
||||
django: ["3.2", "4.2", "5.0", "5.1"]
|
||||
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
exclude:
|
||||
- django: "3.2"
|
||||
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"
|
||||
- django: "5.1"
|
||||
python-version: "3.8"
|
||||
- django: "5.1"
|
||||
python-version: "3.9"
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: actions/checkout@v3
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v1
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Install dependencies
|
||||
|
@ -28,4 +41,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
|
||||
|
|
23
.pre-commit-config.yaml
Normal file
23
.pre-commit-config.yaml
Normal file
|
@ -0,0 +1,23 @@
|
|||
default_language_version:
|
||||
python: python3.11
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
hooks:
|
||||
- id: check-merge-conflict
|
||||
- id: check-json
|
||||
- id: check-yaml
|
||||
- id: debug-statements
|
||||
- id: end-of-file-fixer
|
||||
exclude: ^docs/.*$
|
||||
- id: pretty-format-json
|
||||
args:
|
||||
- --autofix
|
||||
- id: trailing-whitespace
|
||||
exclude: README.md
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.1.2
|
||||
hooks:
|
||||
- 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
|
32
.ruff.toml
Normal file
32
.ruff.toml
Normal file
|
@ -0,0 +1,32 @@
|
|||
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"]
|
||||
|
||||
[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.
|
||||
|
||||
|
@ -59,4 +59,4 @@ Then to produce a HTML version of the documentation:
|
|||
|
||||
```sh
|
||||
make html
|
||||
```
|
||||
```
|
||||
|
|
|
@ -3,4 +3,4 @@ recursive-include graphene_django/templates *
|
|||
recursive-include graphene_django/static *
|
||||
|
||||
include examples/cookbook/cookbook/ingredients/fixtures/ingredients.json
|
||||
include examples/cookbook-plain/cookbook/ingredients/fixtures/ingredients.json
|
||||
include examples/cookbook-plain/cookbook/ingredients/fixtures/ingredients.json
|
||||
|
|
24
Makefile
24
Makefile
|
@ -1,24 +1,24 @@
|
|||
.PHONY: help
|
||||
help:
|
||||
@echo "Please use \`make <target>' where <target> is one of"
|
||||
@grep -E '^\.PHONY: [a-zA-Z_-]+ .*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = "(: |##)"}; {printf "\033[36m%-30s\033[0m %s\n", $$2, $$3}'
|
||||
|
||||
.PHONY: dev-setup ## Install development dependencies
|
||||
dev-setup:
|
||||
pip install -e ".[dev]"
|
||||
python -m pre_commit install
|
||||
|
||||
.PHONY: install-dev
|
||||
install-dev: dev-setup # Alias install-dev -> dev-setup
|
||||
|
||||
.PHONY: tests
|
||||
.PHONY: tests ## Run unit tests
|
||||
tests:
|
||||
py.test graphene_django --cov=graphene_django -vv
|
||||
PYTHONPATH=. pytest graphene_django --cov=graphene_django -vv
|
||||
|
||||
.PHONY: test
|
||||
test: tests # Alias test -> tests
|
||||
|
||||
.PHONY: format
|
||||
.PHONY: format ## Format code
|
||||
format:
|
||||
black --exclude "/migrations/" graphene_django examples setup.py
|
||||
ruff format graphene_django examples setup.py
|
||||
|
||||
.PHONY: lint
|
||||
.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-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>=2.0"
|
||||
```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>=2.0"
|
||||
|
||||
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
|
|
@ -48,6 +48,31 @@ conversely you can use ``exclude`` meta attribute.
|
|||
exclude = ('published', 'owner')
|
||||
interfaces = (relay.Node, )
|
||||
|
||||
|
||||
Another pattern is to have a resolve method act as a gatekeeper, returning None
|
||||
or raising an exception if the client isn't allowed to see the data.
|
||||
|
||||
.. code:: python
|
||||
|
||||
from graphene import relay
|
||||
from graphene_django.types import DjangoObjectType
|
||||
from .models import Post
|
||||
|
||||
class PostNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Post
|
||||
fields = ('title', 'content', 'owner')
|
||||
interfaces = (relay.Node, )
|
||||
|
||||
def resolve_owner(self, info):
|
||||
user = info.context.user
|
||||
if user.is_anonymous:
|
||||
raise PermissionDenied("Please login")
|
||||
if not user.is_staff:
|
||||
return None
|
||||
return self.owner
|
||||
|
||||
|
||||
Queryset Filtering On Lists
|
||||
---------------------------
|
||||
|
||||
|
@ -111,6 +136,7 @@ If you are using ``DjangoObjectType`` you can define a custom `get_queryset`.
|
|||
class PostNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Post
|
||||
fields = '__all__'
|
||||
|
||||
@classmethod
|
||||
def get_queryset(cls, queryset, info):
|
||||
|
@ -118,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
|
||||
------------------------------
|
||||
|
@ -166,22 +207,13 @@ To restrict users from accessing the GraphQL API page the standard Django LoginR
|
|||
|
||||
After this, you can use the new ``PrivateGraphQLView`` in the project's URL Configuration file ``url.py``:
|
||||
|
||||
For Django 1.11:
|
||||
For Django 2.2 and above:
|
||||
|
||||
.. code:: python
|
||||
|
||||
urlpatterns = [
|
||||
# some other urls
|
||||
url(r'^graphql$', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
|
||||
# some other urls
|
||||
path('graphql/', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
|
||||
]
|
||||
|
||||
For Django 2.0 and above:
|
||||
|
||||
.. code:: python
|
||||
|
||||
urlpatterns = [
|
||||
# some other urls
|
||||
path('graphql', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
|
||||
]
|
||||
|
||||
.. _LoginRequiredMixin: https://docs.djangoproject.com/en/1.10/topics/auth/default/#the-loginrequired-mixin
|
||||
.. _LoginRequiredMixin: https://docs.djangoproject.com/en/dev/topics/auth/default/#the-loginrequired-mixin
|
||||
|
|
23
docs/conf.py
23
docs/conf.py
|
@ -60,25 +60,25 @@ source_suffix = ".rst"
|
|||
master_doc = "index"
|
||||
|
||||
# General information about the project.
|
||||
project = u"Graphene Django"
|
||||
copyright = u"Graphene 2017"
|
||||
author = u"Syrus Akbary"
|
||||
project = "Graphene Django"
|
||||
copyright = "Graphene 2017"
|
||||
author = "Syrus Akbary"
|
||||
|
||||
# The version info for the project you're documenting, acts as replacement for
|
||||
# |version| and |release|, also used in various other places throughout the
|
||||
# built documents.
|
||||
#
|
||||
# The short X.Y version.
|
||||
version = u"1.0"
|
||||
version = "1.0"
|
||||
# The full version, including alpha/beta/rc tags.
|
||||
release = u"1.0.dev"
|
||||
release = "1.0.dev"
|
||||
|
||||
# The language for content autogenerated by Sphinx. Refer to documentation
|
||||
# for a list of supported languages.
|
||||
#
|
||||
# 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:
|
||||
|
@ -276,7 +276,7 @@ latex_elements = {
|
|||
# (source start file, target name, title,
|
||||
# author, documentclass [howto, manual, or own class]).
|
||||
latex_documents = [
|
||||
(master_doc, "Graphene.tex", u"Graphene Documentation", u"Syrus Akbary", "manual")
|
||||
(master_doc, "Graphene.tex", "Graphene Documentation", "Syrus Akbary", "manual")
|
||||
]
|
||||
|
||||
# The name of an image file (relative to this directory) to place at the top of
|
||||
|
@ -317,7 +317,7 @@ latex_documents = [
|
|||
# One entry per manual page. List of tuples
|
||||
# (source start file, name, description, authors, manual section).
|
||||
man_pages = [
|
||||
(master_doc, "graphene_django", u"Graphene Django Documentation", [author], 1)
|
||||
(master_doc, "graphene_django", "Graphene Django Documentation", [author], 1)
|
||||
]
|
||||
|
||||
# If true, show URL addresses after external links.
|
||||
|
@ -334,7 +334,7 @@ texinfo_documents = [
|
|||
(
|
||||
master_doc,
|
||||
"Graphene-Django",
|
||||
u"Graphene Django Documentation",
|
||||
"Graphene Django Documentation",
|
||||
author,
|
||||
"Graphene Django",
|
||||
"One line description of project.",
|
||||
|
@ -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),
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ Django Debug Middleware
|
|||
You can debug your GraphQL queries in a similar way to
|
||||
`django-debug-toolbar <https://django-debug-toolbar.readthedocs.org/>`__,
|
||||
but outputting in the results in GraphQL response as fields, instead of
|
||||
the graphical HTML interface.
|
||||
the graphical HTML interface. Exceptions with their stack traces are also exposed.
|
||||
|
||||
For that, you will need to add the plugin in your graphene schema.
|
||||
|
||||
|
@ -63,6 +63,10 @@ the GraphQL request, like:
|
|||
sql {
|
||||
rawSql
|
||||
}
|
||||
exceptions {
|
||||
message
|
||||
stack
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -80,4 +80,6 @@ published and have a title:
|
|||
DjangoConnectionField
|
||||
---------------------
|
||||
|
||||
*TODO*
|
||||
``DjangoConnectionField`` acts similarly to ``DjangoListField`` but returns a
|
||||
paginated connection following the `relay spec <https://relay.dev/graphql/connections.htm>`__
|
||||
The field supports the following arguments: `first`, `last`, `offset`, `after` & `before`.
|
||||
|
|
|
@ -1,10 +1,9 @@
|
|||
Filtering
|
||||
=========
|
||||
|
||||
Graphene-Django integrates with
|
||||
`django-filter <https://django-filter.readthedocs.io/en/master/>`__ (2.x for
|
||||
Python 3 or 1.x for Python 2) to provide filtering of results. See the `usage
|
||||
documentation <https://django-filter.readthedocs.io/en/master/guide/usage.html#the-filter>`__
|
||||
Graphene integrates with
|
||||
`django-filter <https://django-filter.readthedocs.io/en/stable/>`__ to provide filtering of results.
|
||||
See the `usage documentation <https://django-filter.readthedocs.io/en/stable/guide/usage.html#the-filter>`__
|
||||
for details on the format for ``filter_fields``.
|
||||
|
||||
This filtering is automatically available when implementing a ``relay.Node``.
|
||||
|
@ -16,7 +15,7 @@ You will need to install it manually, which can be done as follows:
|
|||
|
||||
# You'll need to install django-filter
|
||||
pip install django-filter>=2
|
||||
|
||||
|
||||
After installing ``django-filter`` you'll need to add the application in the ``settings.py`` file:
|
||||
|
||||
.. code:: python
|
||||
|
@ -27,7 +26,7 @@ After installing ``django-filter`` you'll need to add the application in the ``s
|
|||
]
|
||||
|
||||
Note: The techniques below are demoed in the `cookbook example
|
||||
app <https://github.com/graphql-python/graphene-django/tree/master/examples/cookbook>`__.
|
||||
app <https://github.com/graphql-python/graphene-django/tree/main/examples/cookbook>`__.
|
||||
|
||||
Filterable fields
|
||||
-----------------
|
||||
|
@ -35,7 +34,7 @@ Filterable fields
|
|||
The ``filter_fields`` parameter is used to specify the fields which can
|
||||
be filtered upon. The value specified here is passed directly to
|
||||
``django-filter``, so see the `filtering
|
||||
documentation <https://django-filter.readthedocs.io/en/master/guide/usage.html#the-filter>`__
|
||||
documentation <https://django-filter.readthedocs.io/en/main/guide/usage.html#the-filter>`__
|
||||
for full details on the range of options available.
|
||||
|
||||
For example:
|
||||
|
@ -46,6 +45,7 @@ For example:
|
|||
class Meta:
|
||||
# Assume you have an Animal model defined with the following fields
|
||||
model = Animal
|
||||
fields = '__all__'
|
||||
filter_fields = ['name', 'genus', 'is_domesticated']
|
||||
interfaces = (relay.Node, )
|
||||
|
||||
|
@ -76,6 +76,7 @@ You can also make more complex lookup types available:
|
|||
class AnimalNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Animal
|
||||
fields = '__all__'
|
||||
# Provide more complex lookup types
|
||||
filter_fields = {
|
||||
'name': ['exact', 'icontains', 'istartswith'],
|
||||
|
@ -117,6 +118,7 @@ create your own ``FilterSet``. You can pass it directly as follows:
|
|||
class Meta:
|
||||
# Assume you have an Animal model defined with the following fields
|
||||
model = Animal
|
||||
fields = '__all__'
|
||||
filter_fields = ['name', 'genus', 'is_domesticated']
|
||||
interfaces = (relay.Node, )
|
||||
|
||||
|
@ -124,6 +126,15 @@ create your own ``FilterSet``. You can pass it directly as follows:
|
|||
class AnimalFilter(django_filters.FilterSet):
|
||||
# Do case-insensitive lookups on 'name'
|
||||
name = django_filters.CharFilter(lookup_expr=['iexact'])
|
||||
# Allow multiple genera to be selected at once
|
||||
genera = django_filters.MultipleChoiceFilter(
|
||||
field_name='genus',
|
||||
choices=(
|
||||
('Canis', 'Canis'),
|
||||
('Panthera', 'Panthera'),
|
||||
('Seahorse', 'Seahorse')
|
||||
)
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Animal
|
||||
|
@ -136,6 +147,22 @@ create your own ``FilterSet``. You can pass it directly as follows:
|
|||
all_animals = DjangoFilterConnectionField(AnimalNode,
|
||||
filterset_class=AnimalFilter)
|
||||
|
||||
|
||||
If you were interested in selecting all dogs and cats, you might query as follows:
|
||||
|
||||
.. code::
|
||||
|
||||
query {
|
||||
allAnimals(genera: ["Canis", "Panthera"]) {
|
||||
edges {
|
||||
node {
|
||||
id,
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
You can also specify the ``FilterSet`` class using the ``filterset_class``
|
||||
parameter when defining your ``DjangoObjectType``, however, this can't be used
|
||||
in unison with the ``filter_fields`` parameter:
|
||||
|
@ -155,6 +182,7 @@ in unison with the ``filter_fields`` parameter:
|
|||
class AnimalNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Animal
|
||||
fields = '__all__'
|
||||
filterset_class = AnimalFilter
|
||||
interfaces = (relay.Node, )
|
||||
|
||||
|
@ -163,7 +191,8 @@ in unison with the ``filter_fields`` parameter:
|
|||
animal = relay.Node.Field(AnimalNode)
|
||||
all_animals = DjangoFilterConnectionField(AnimalNode)
|
||||
|
||||
The context argument is passed on as the `request argument <http://django-filter.readthedocs.io/en/master/guide/usage.html#request-based-filtering>`__
|
||||
|
||||
The context argument is passed on as the `request argument <http://django-filter.readthedocs.io/en/main/guide/usage.html#request-based-filtering>`__
|
||||
in a ``django_filters.FilterSet`` instance. You can use this to customize your
|
||||
filters to be context-dependent. We could modify the ``AnimalFilter`` above to
|
||||
pre-filter animals owned by the authenticated user (set in ``context.user``).
|
||||
|
@ -211,6 +240,7 @@ Extend the tuple of fields if you want to order by more than one field.
|
|||
class Meta:
|
||||
name = 'Group'
|
||||
model = GroupModel
|
||||
fields = '__all__'
|
||||
interfaces = (relay.Node,)
|
||||
|
||||
def resolve_users(self, info, **kwargs):
|
||||
|
@ -228,3 +258,86 @@ with this set up, you can now order the users under group:
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
PostgreSQL `ArrayField`
|
||||
-----------------------
|
||||
|
||||
Graphene provides an easy to implement filters on `ArrayField` as they are not natively supported by django_filters:
|
||||
|
||||
.. code:: python
|
||||
|
||||
from django.db import models
|
||||
from django_filters import FilterSet, OrderingFilter
|
||||
from graphene_django.filter import ArrayFilter
|
||||
|
||||
class Event(models.Model):
|
||||
name = models.CharField(max_length=50)
|
||||
tags = ArrayField(models.CharField(max_length=50))
|
||||
|
||||
class EventFilterSet(FilterSet):
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = {
|
||||
"name": ["exact", "contains"],
|
||||
}
|
||||
|
||||
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")
|
||||
|
||||
class EventType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Event
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filterset_class = EventFilterSet
|
||||
|
||||
with this set up, you can now filter events by tags:
|
||||
|
||||
.. code::
|
||||
|
||||
query {
|
||||
events(tags_Overlap: ["concert", "festival"]) {
|
||||
name
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
`TypedFilter`
|
||||
-------------
|
||||
|
||||
Sometimes the automatic detection of the filter input type is not satisfactory for what you are trying to achieve.
|
||||
You can then explicitly specify the input type you want for your filter by using a `TypedFilter`:
|
||||
|
||||
.. code:: python
|
||||
|
||||
from django.db import models
|
||||
from django_filters import FilterSet, OrderingFilter
|
||||
import graphene
|
||||
from graphene_django.filter import TypedFilter
|
||||
|
||||
class Event(models.Model):
|
||||
name = models.CharField(max_length=50)
|
||||
|
||||
class EventFilterSet(FilterSet):
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = {
|
||||
"name": ["exact", "contains"],
|
||||
}
|
||||
|
||||
only_first = TypedFilter(input_type=graphene.Boolean, method="only_first_filter")
|
||||
|
||||
def only_first_filter(self, queryset, _name, value):
|
||||
if value:
|
||||
return queryset[:1]
|
||||
else:
|
||||
return queryset
|
||||
|
||||
class EventType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Event
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filterset_class = EventFilterSet
|
||||
|
|
|
@ -33,5 +33,6 @@ For more advanced use, check out the Relay tutorial.
|
|||
authorization
|
||||
debug
|
||||
introspection
|
||||
validation
|
||||
testing
|
||||
settings
|
||||
|
|
|
@ -8,7 +8,7 @@ Requirements
|
|||
|
||||
Graphene-Django currently supports the following versions of Django:
|
||||
|
||||
* >= Django 1.11
|
||||
* >= Django 2.2
|
||||
|
||||
Installation
|
||||
------------
|
||||
|
@ -32,19 +32,7 @@ Add ``graphene_django`` to the ``INSTALLED_APPS`` in the ``settings.py`` file of
|
|||
|
||||
We need to add a ``graphql`` URL to the ``urls.py`` of your Django project:
|
||||
|
||||
For Django 1.11:
|
||||
|
||||
.. code:: python
|
||||
|
||||
from django.conf.urls import url
|
||||
from graphene_django.views import GraphQLView
|
||||
|
||||
urlpatterns = [
|
||||
# ...
|
||||
url(r"graphql", GraphQLView.as_view(graphiql=True)),
|
||||
]
|
||||
|
||||
For Django 2.0 and above:
|
||||
For Django 2.2 and above:
|
||||
|
||||
.. code:: python
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -25,6 +25,7 @@ Simple example
|
|||
class QuestionType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
class QuestionMutation(graphene.Mutation):
|
||||
|
@ -91,6 +92,7 @@ DjangoModelFormMutation
|
|||
class PetType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Pet
|
||||
fields = '__all__'
|
||||
|
||||
class PetMutation(DjangoModelFormMutation):
|
||||
pet = Field(PetType)
|
||||
|
@ -123,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
|
||||
---------------------
|
||||
|
|
|
@ -52,6 +52,9 @@ all fields that should be exposed using the fields attribute.
|
|||
This will make it less likely to result in unintentionally exposing data when
|
||||
your models change.
|
||||
|
||||
Setting neither ``fields`` nor ``exclude`` is deprecated and will raise a warning, you should at least explicitly make
|
||||
``DjangoObjectType`` include all fields in the model as described below.
|
||||
|
||||
``fields``
|
||||
~~~~~~~~~~
|
||||
|
||||
|
@ -148,7 +151,7 @@ For example the following ``Model`` and ``DjangoObjectType``:
|
|||
|
||||
Results in the following GraphQL schema definition:
|
||||
|
||||
.. code::
|
||||
.. code:: graphql
|
||||
|
||||
type Pet {
|
||||
id: ID!
|
||||
|
@ -175,7 +178,7 @@ You can disable this automatic conversion by setting
|
|||
fields = ("id", "kind",)
|
||||
convert_choices_to_enum = False
|
||||
|
||||
.. code::
|
||||
.. code:: graphql
|
||||
|
||||
type Pet {
|
||||
id: ID!
|
||||
|
@ -258,6 +261,7 @@ Use this to control filtering on the ObjectType level instead of the Query objec
|
|||
class QuestionType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = "__all__"
|
||||
|
||||
@classmethod
|
||||
def get_queryset(cls, queryset, info):
|
||||
|
@ -309,7 +313,7 @@ Additionally, Resolvers will receive **any arguments declared in the field defin
|
|||
bar=graphene.Int()
|
||||
)
|
||||
|
||||
def resolve_question(root, info, foo, bar):
|
||||
def resolve_question(root, info, foo=None, bar=None):
|
||||
# If `foo` or `bar` are declared in the GraphQL query they will be here, else None.
|
||||
return Question.objects.filter(foo=foo, bar=bar).first()
|
||||
|
||||
|
@ -332,12 +336,12 @@ of Django's ``HTTPRequest`` in your resolve methods, such as checking for authen
|
|||
class Query(graphene.ObjectType):
|
||||
questions = graphene.List(QuestionType)
|
||||
|
||||
def resolve_questions(root, info):
|
||||
# See if a user is authenticated
|
||||
if info.context.user.is_authenticated():
|
||||
return Question.objects.all()
|
||||
else:
|
||||
return Question.objects.none()
|
||||
def resolve_questions(root, info):
|
||||
# See if a user is authenticated
|
||||
if info.context.user.is_authenticated():
|
||||
return Question.objects.all()
|
||||
else:
|
||||
return Question.objects.none()
|
||||
|
||||
|
||||
DjangoObjectTypes
|
||||
|
@ -414,29 +418,29 @@ the core graphene pages for more information on customizing the Relay experience
|
|||
You can now execute queries like:
|
||||
|
||||
|
||||
.. code:: python
|
||||
.. code:: graphql
|
||||
|
||||
{
|
||||
questions (first: 2, after: "YXJyYXljb25uZWN0aW9uOjEwNQ==") {
|
||||
pageInfo {
|
||||
startCursor
|
||||
endCursor
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
startCursor
|
||||
endCursor
|
||||
hasNextPage
|
||||
hasPreviousPage
|
||||
}
|
||||
edges {
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
question_text
|
||||
}
|
||||
cursor
|
||||
node {
|
||||
id
|
||||
question_text
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Which returns:
|
||||
|
||||
.. code:: python
|
||||
.. code:: json
|
||||
|
||||
{
|
||||
"data": {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -1,58 +1,57 @@
|
|||
import graphene
|
||||
import graphene
|
||||
|
||||
from graphene_django.types import DjangoObjectType
|
||||
from graphene_django.types import DjangoObjectType
|
||||
|
||||
from cookbook.ingredients.models import Category, Ingredient
|
||||
from cookbook.ingredients.models import Category, Ingredient
|
||||
|
||||
|
||||
class CategoryType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Category
|
||||
class CategoryType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Category
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class IngredientType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Ingredient
|
||||
class IngredientType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Ingredient
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class Query(object):
|
||||
category = graphene.Field(CategoryType,
|
||||
id=graphene.Int(),
|
||||
name=graphene.String())
|
||||
all_categories = graphene.List(CategoryType)
|
||||
class Query:
|
||||
category = graphene.Field(CategoryType, id=graphene.Int(), name=graphene.String())
|
||||
all_categories = graphene.List(CategoryType)
|
||||
|
||||
ingredient = graphene.Field(
|
||||
IngredientType, id=graphene.Int(), name=graphene.String()
|
||||
)
|
||||
all_ingredients = graphene.List(IngredientType)
|
||||
|
||||
ingredient = graphene.Field(IngredientType,
|
||||
id=graphene.Int(),
|
||||
name=graphene.String())
|
||||
all_ingredients = graphene.List(IngredientType)
|
||||
def resolve_all_categories(self, info, **kwargs):
|
||||
return Category.objects.all()
|
||||
|
||||
def resolve_all_categories(self, info, **kwargs):
|
||||
return Category.objects.all()
|
||||
def resolve_all_ingredients(self, info, **kwargs):
|
||||
return Ingredient.objects.all()
|
||||
|
||||
def resolve_all_ingredients(self, info, **kwargs):
|
||||
return Ingredient.objects.all()
|
||||
def resolve_category(self, info, **kwargs):
|
||||
id = kwargs.get("id")
|
||||
name = kwargs.get("name")
|
||||
|
||||
def resolve_category(self, info, **kwargs):
|
||||
id = kwargs.get('id')
|
||||
name = kwargs.get('name')
|
||||
if id is not None:
|
||||
return Category.objects.get(pk=id)
|
||||
|
||||
if id is not None:
|
||||
return Category.objects.get(pk=id)
|
||||
if name is not None:
|
||||
return Category.objects.get(name=name)
|
||||
|
||||
if name is not None:
|
||||
return Category.objects.get(name=name)
|
||||
return None
|
||||
|
||||
return None
|
||||
def resolve_ingredient(self, info, **kwargs):
|
||||
id = kwargs.get("id")
|
||||
name = kwargs.get("name")
|
||||
|
||||
def resolve_ingredient(self, info, **kwargs):
|
||||
id = kwargs.get('id')
|
||||
name = kwargs.get('name')
|
||||
if id is not None:
|
||||
return Ingredient.objects.get(pk=id)
|
||||
|
||||
if id is not None:
|
||||
return Ingredient.objects.get(pk=id)
|
||||
if name is not None:
|
||||
return Ingredient.objects.get(name=name)
|
||||
|
||||
if name is not None:
|
||||
return Ingredient.objects.get(name=name)
|
||||
|
||||
return None
|
||||
return None
|
||||
|
|
|
@ -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,10 +142,19 @@ Default: ``False``
|
|||
# ]
|
||||
|
||||
|
||||
``DJANGO_CHOICE_FIELD_ENUM_V3_NAMING``
|
||||
``DJANGO_CHOICE_FIELD_ENUM_CONVERT``
|
||||
--------------------------------------
|
||||
|
||||
Set to ``True`` to use the new naming format for the auto generated Enum types from Django choice fields. The new format looks like this: ``{app_label}{object_name}{field_name}Choices``
|
||||
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``
|
||||
--------------------------------------
|
||||
|
||||
Set to ``True`` to use the old naming format for the auto generated Enum types from Django choice fields. The old format looks like this: ``{object_name}_{field_name}``
|
||||
|
||||
Default: ``False``
|
||||
|
||||
|
@ -155,7 +164,7 @@ Default: ``False``
|
|||
|
||||
Define the path of a function that takes the Django choice field and returns a string to completely customise the naming for the Enum type.
|
||||
|
||||
If set to a function then the ``DJANGO_CHOICE_FIELD_ENUM_V3_NAMING`` setting is ignored.
|
||||
If set to a function then the ``DJANGO_CHOICE_FIELD_ENUM_V2_NAMING`` setting is ignored.
|
||||
|
||||
Default: ``None``
|
||||
|
||||
|
@ -189,7 +198,7 @@ Default: ``None``
|
|||
|
||||
|
||||
``GRAPHIQL_HEADER_EDITOR_ENABLED``
|
||||
---------------------
|
||||
----------------------------------
|
||||
|
||||
GraphiQL starting from version 1.0.0 allows setting custom headers in similar fashion to query variables.
|
||||
|
||||
|
@ -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
|
||||
|
@ -207,3 +213,79 @@ Default: ``True``
|
|||
GRAPHENE = {
|
||||
'GRAPHIQL_HEADER_EDITOR_ENABLED': True,
|
||||
}
|
||||
|
||||
|
||||
``TESTING_ENDPOINT``
|
||||
--------------------
|
||||
|
||||
Define the graphql endpoint url used for the `GraphQLTestCase` class.
|
||||
|
||||
Default: ``/graphql``
|
||||
|
||||
.. code:: python
|
||||
|
||||
GRAPHENE = {
|
||||
'TESTING_ENDPOINT': '/customEndpoint'
|
||||
}
|
||||
|
||||
|
||||
``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_.
|
||||
|
||||
|
||||
Default: ``False``
|
||||
|
||||
.. code:: python
|
||||
|
||||
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``
|
||||
|
|
|
@ -6,7 +6,8 @@ Using unittest
|
|||
|
||||
If you want to unittest your API calls derive your test case from the class `GraphQLTestCase`.
|
||||
|
||||
Your endpoint is set through the `GRAPHQL_URL` attribute on `GraphQLTestCase`. The default endpoint is `GRAPHQL_URL = "/graphql/"`.
|
||||
The default endpoint for testing is `/graphql`. You can override this in the `settings <https://docs.graphene-python.org/projects/django/en/latest/settings/#testing-endpoint>`__.
|
||||
|
||||
|
||||
Usage:
|
||||
|
||||
|
@ -27,7 +28,7 @@ Usage:
|
|||
}
|
||||
}
|
||||
''',
|
||||
op_name='myModel'
|
||||
operation_name='myModel'
|
||||
)
|
||||
|
||||
content = json.loads(response.content)
|
||||
|
@ -48,7 +49,7 @@ Usage:
|
|||
}
|
||||
}
|
||||
''',
|
||||
op_name='myModel',
|
||||
operation_name='myModel',
|
||||
variables={'id': 1}
|
||||
)
|
||||
|
||||
|
@ -72,7 +73,42 @@ Usage:
|
|||
}
|
||||
}
|
||||
''',
|
||||
op_name='myMutation',
|
||||
operation_name='myMutation',
|
||||
input_data={'my_field': 'foo', 'other_field': 'bar'}
|
||||
)
|
||||
|
||||
# This validates the status code and if you get errors
|
||||
self.assertResponseNoErrors(response)
|
||||
|
||||
# Add some more asserts if you like
|
||||
...
|
||||
|
||||
|
||||
For testing mutations that are executed within a transaction you should subclass `GraphQLTransactionTestCase`
|
||||
|
||||
Usage:
|
||||
|
||||
.. code:: python
|
||||
|
||||
import json
|
||||
|
||||
from graphene_django.utils.testing import GraphQLTransactionTestCase
|
||||
|
||||
class MyFancyTransactionTestCase(GraphQLTransactionTestCase):
|
||||
|
||||
def test_some_mutation_that_executes_within_a_transaction(self):
|
||||
response = self.query(
|
||||
'''
|
||||
mutation myMutation($input: MyMutationInput!) {
|
||||
myMutation(input: $input) {
|
||||
my-model {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
''',
|
||||
operation_name='myMutation',
|
||||
input_data={'my_field': 'foo', 'other_field': 'bar'}
|
||||
)
|
||||
|
||||
|
@ -112,7 +148,7 @@ To use pytest define a simple fixture using the query helper below
|
|||
}
|
||||
}
|
||||
''',
|
||||
op_name='myModel'
|
||||
operation_name='myModel'
|
||||
)
|
||||
|
||||
content = json.loads(response.content)
|
||||
|
|
|
@ -35,6 +35,7 @@ Now sync your database for the first time:
|
|||
|
||||
.. code:: bash
|
||||
|
||||
cd ..
|
||||
python manage.py migrate
|
||||
|
||||
Let's create a few simple models...
|
||||
|
@ -77,6 +78,18 @@ Add ingredients as INSTALLED_APPS:
|
|||
"cookbook.ingredients",
|
||||
]
|
||||
|
||||
Make sure the app name in ``cookbook.ingredients.apps.IngredientsConfig`` is set to ``cookbook.ingredients``.
|
||||
|
||||
.. code:: python
|
||||
|
||||
# cookbook/ingredients/apps.py
|
||||
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class IngredientsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'cookbook.ingredients'
|
||||
|
||||
Don't forget to create & run migrations:
|
||||
|
||||
|
@ -91,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
|
||||
------------------------
|
||||
|
@ -70,7 +70,7 @@ Let's get started with these models:
|
|||
class Ingredient(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
notes = models.TextField()
|
||||
category = models.ForeignKey(Category, related_name='ingredients')
|
||||
category = models.ForeignKey(Category, related_name='ingredients', on_delete=models.CASCADE)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
@ -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:
|
||||
|
@ -132,6 +132,7 @@ Create ``cookbook/ingredients/schema.py`` and type the following:
|
|||
class CategoryNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Category
|
||||
fields = '__all__'
|
||||
filter_fields = ['name', 'ingredients']
|
||||
interfaces = (relay.Node, )
|
||||
|
||||
|
@ -139,6 +140,7 @@ Create ``cookbook/ingredients/schema.py`` and type the following:
|
|||
class IngredientNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Ingredient
|
||||
fields = '__all__'
|
||||
# Allow for some more advanced filtering here
|
||||
filter_fields = {
|
||||
'name': ['exact', 'icontains', 'istartswith'],
|
||||
|
@ -149,7 +151,7 @@ Create ``cookbook/ingredients/schema.py`` and type the following:
|
|||
interfaces = (relay.Node, )
|
||||
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
class Query(ObjectType):
|
||||
category = relay.Node.Field(CategoryNode)
|
||||
all_categories = DjangoFilterConnectionField(CategoryNode)
|
||||
|
||||
|
@ -279,7 +281,7 @@ from the command line.
|
|||
$ python ./manage.py runserver
|
||||
|
||||
Performing system checks...
|
||||
Django version 1.11, using settings 'cookbook.settings'
|
||||
Django version 3.1.7, using settings 'cookbook.settings'
|
||||
Starting development server at http://127.0.0.1:8000/
|
||||
Quit the server with CONTROL-C.
|
||||
|
||||
|
|
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()),
|
||||
]
|
|
@ -14,7 +14,7 @@ whole Graphene repository:
|
|||
```bash
|
||||
# Get the example project code
|
||||
git clone https://github.com/graphql-python/graphene-django.git
|
||||
cd graphene-django/examples/cookbook
|
||||
cd graphene-django/examples/cookbook-plain
|
||||
```
|
||||
|
||||
It is good idea (but not required) to create a virtual environment
|
||||
|
@ -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 +1,52 @@
|
|||
[{"model": "ingredients.category", "pk": 1, "fields": {"name": "Dairy"}}, {"model": "ingredients.category", "pk": 2, "fields": {"name": "Meat"}}, {"model": "ingredients.ingredient", "pk": 1, "fields": {"name": "Eggs", "notes": "Good old eggs", "category": 1}}, {"model": "ingredients.ingredient", "pk": 2, "fields": {"name": "Milk", "notes": "Comes from a cow", "category": 1}}, {"model": "ingredients.ingredient", "pk": 3, "fields": {"name": "Beef", "notes": "Much like milk, this comes from a cow", "category": 2}}, {"model": "ingredients.ingredient", "pk": 4, "fields": {"name": "Chicken", "notes": "Definitely doesn't come from a cow", "category": 2}}]
|
||||
[
|
||||
{
|
||||
"fields": {
|
||||
"name": "Dairy"
|
||||
},
|
||||
"model": "ingredients.category",
|
||||
"pk": 1
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"name": "Meat"
|
||||
},
|
||||
"model": "ingredients.category",
|
||||
"pk": 2
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"category": 1,
|
||||
"name": "Eggs",
|
||||
"notes": "Good old eggs"
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 1
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"category": 1,
|
||||
"name": "Milk",
|
||||
"notes": "Comes from a cow"
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 2
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"category": 2,
|
||||
"name": "Beef",
|
||||
"notes": "Much like milk, this comes from a cow"
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 3
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"category": 2,
|
||||
"name": "Chicken",
|
||||
"notes": "Definitely doesn't come from a cow"
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 4
|
||||
}
|
||||
]
|
||||
|
|
|
@ -1,33 +1,52 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9 on 2015-12-04 18:15
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Category',
|
||||
name="Category",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=100)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Ingredient',
|
||||
name="Ingredient",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('notes', models.TextField()),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredients', to='ingredients.Category')),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=100)),
|
||||
("notes", models.TextField()),
|
||||
(
|
||||
"category",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="ingredients",
|
||||
to="ingredients.Category",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,20 +1,17 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9 on 2016-11-04 00:50
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ingredients', '0001_initial'),
|
||||
("ingredients", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='ingredient',
|
||||
name='notes',
|
||||
model_name="ingredient",
|
||||
name="notes",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -4,14 +4,13 @@ from django.db import migrations
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ingredients', '0002_auto_20161104_0050'),
|
||||
("ingredients", "0002_auto_20161104_0050"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='category',
|
||||
options={'verbose_name_plural': 'Categories'},
|
||||
name="category",
|
||||
options={"verbose_name_plural": "Categories"},
|
||||
),
|
||||
]
|
||||
|
|
|
@ -7,14 +7,16 @@ from .models import Category, Ingredient
|
|||
class CategoryType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Category
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class IngredientType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Ingredient
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class Query(object):
|
||||
class Query:
|
||||
category = graphene.Field(CategoryType, id=graphene.Int(), name=graphene.String())
|
||||
all_categories = graphene.List(CategoryType)
|
||||
|
||||
|
|
|
@ -1,36 +1,69 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9 on 2015-12-04 18:20
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('ingredients', '0001_initial'),
|
||||
("ingredients", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Recipe',
|
||||
name="Recipe",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=100)),
|
||||
('instructions', models.TextField()),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=100)),
|
||||
("instructions", models.TextField()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RecipeIngredient',
|
||||
name="RecipeIngredient",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('amount', models.FloatField()),
|
||||
('unit', models.CharField(choices=[('kg', 'Kilograms'), ('l', 'Litres'), ('', 'Units')], max_length=20)),
|
||||
('ingredient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='used_by', to='ingredients.Ingredient')),
|
||||
('recipes', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='amounts', to='recipes.Recipe')),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("amount", models.FloatField()),
|
||||
(
|
||||
"unit",
|
||||
models.CharField(
|
||||
choices=[("kg", "Kilograms"), ("l", "Litres"), ("", "Units")],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"ingredient",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="used_by",
|
||||
to="ingredients.Ingredient",
|
||||
),
|
||||
),
|
||||
(
|
||||
"recipes",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="amounts",
|
||||
to="recipes.Recipe",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,25 +1,30 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9 on 2016-11-04 01:06
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recipes', '0001_initial'),
|
||||
("recipes", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='recipeingredient',
|
||||
old_name='recipes',
|
||||
new_name='recipe',
|
||||
model_name="recipeingredient",
|
||||
old_name="recipes",
|
||||
new_name="recipe",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='recipeingredient',
|
||||
name='unit',
|
||||
field=models.CharField(choices=[(b'unit', b'Units'), (b'kg', b'Kilograms'), (b'l', b'Litres'), (b'st', b'Shots')], max_length=20),
|
||||
model_name="recipeingredient",
|
||||
name="unit",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
(b"unit", b"Units"),
|
||||
(b"kg", b"Kilograms"),
|
||||
(b"l", b"Litres"),
|
||||
(b"st", b"Shots"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -4,15 +4,22 @@ from django.db import migrations, models
|
|||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recipes', '0002_auto_20161104_0106'),
|
||||
("recipes", "0002_auto_20161104_0106"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='recipeingredient',
|
||||
name='unit',
|
||||
field=models.CharField(choices=[('unit', 'Units'), ('kg', 'Kilograms'), ('l', 'Litres'), ('st', 'Shots')], max_length=20),
|
||||
model_name="recipeingredient",
|
||||
name="unit",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
("unit", "Units"),
|
||||
("kg", "Kilograms"),
|
||||
("l", "Litres"),
|
||||
("st", "Shots"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -7,14 +7,16 @@ from .models import Recipe, RecipeIngredient
|
|||
class RecipeType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Recipe
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class RecipeIngredientType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = RecipeIngredient
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class Query(object):
|
||||
class Query:
|
||||
recipe = graphene.Field(RecipeType, id=graphene.Int(), title=graphene.String())
|
||||
all_recipes = graphene.List(RecipeType)
|
||||
|
||||
|
|
|
@ -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.0.7
|
||||
django~=3.2
|
||||
graphene
|
||||
graphene-django>=3.1
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
Cookbook Example Django Project
|
||||
Cookbook Example (Relay) Django Project
|
||||
===============================
|
||||
|
||||
This example project demos integration between Graphene and Django.
|
||||
|
@ -60,5 +60,5 @@ Now you should be ready to start the server:
|
|||
Now head on over to
|
||||
[http://127.0.0.1:8000/graphql](http://127.0.0.1:8000/graphql)
|
||||
and run some queries!
|
||||
(See the [Graphene-Django Tutorial](http://docs.graphene-python.org/projects/django/en/latest/tutorial-plain/#testing-our-graphql-schema)
|
||||
(See the [Graphene-Django Tutorial](http://docs.graphene-python.org/projects/django/en/latest/tutorial-relay/#testing-our-graphql-schema)
|
||||
for some example queries)
|
||||
|
|
|
@ -1 +1,52 @@
|
|||
[{"model": "ingredients.category", "pk": 1, "fields": {"name": "Dairy"}}, {"model": "ingredients.category", "pk": 2, "fields": {"name": "Meat"}}, {"model": "ingredients.ingredient", "pk": 1, "fields": {"name": "Eggs", "notes": "Good old eggs", "category": 1}}, {"model": "ingredients.ingredient", "pk": 2, "fields": {"name": "Milk", "notes": "Comes from a cow", "category": 1}}, {"model": "ingredients.ingredient", "pk": 3, "fields": {"name": "Beef", "notes": "Much like milk, this comes from a cow", "category": 2}}, {"model": "ingredients.ingredient", "pk": 4, "fields": {"name": "Chicken", "notes": "Definitely doesn't come from a cow", "category": 2}}]
|
||||
[
|
||||
{
|
||||
"fields": {
|
||||
"name": "Dairy"
|
||||
},
|
||||
"model": "ingredients.category",
|
||||
"pk": 1
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"name": "Meat"
|
||||
},
|
||||
"model": "ingredients.category",
|
||||
"pk": 2
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"category": 1,
|
||||
"name": "Eggs",
|
||||
"notes": "Good old eggs"
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 1
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"category": 1,
|
||||
"name": "Milk",
|
||||
"notes": "Comes from a cow"
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 2
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"category": 2,
|
||||
"name": "Beef",
|
||||
"notes": "Much like milk, this comes from a cow"
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 3
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"category": 2,
|
||||
"name": "Chicken",
|
||||
"notes": "Definitely doesn't come from a cow"
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 4
|
||||
}
|
||||
]
|
||||
|
|
|
@ -1,33 +1,52 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9 on 2015-12-04 18:15
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
dependencies = []
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Category',
|
||||
name="Category",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=100)),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Ingredient',
|
||||
name="Ingredient",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=100)),
|
||||
('notes', models.TextField()),
|
||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredients', to='ingredients.Category')),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=100)),
|
||||
("notes", models.TextField()),
|
||||
(
|
||||
"category",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="ingredients",
|
||||
to="ingredients.Category",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,20 +1,17 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9 on 2016-11-04 00:50
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('ingredients', '0001_initial'),
|
||||
("ingredients", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='ingredient',
|
||||
name='notes',
|
||||
model_name="ingredient",
|
||||
name="notes",
|
||||
field=models.TextField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -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)
|
||||
|
@ -10,6 +11,7 @@ class CategoryNode(DjangoObjectType):
|
|||
class Meta:
|
||||
model = Category
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = ["name", "ingredients"]
|
||||
|
||||
|
||||
|
@ -18,6 +20,7 @@ class IngredientNode(DjangoObjectType):
|
|||
model = Ingredient
|
||||
# Allow for some more advanced filtering here
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = {
|
||||
"name": ["exact", "icontains", "istartswith"],
|
||||
"notes": ["exact", "icontains"],
|
||||
|
@ -26,7 +29,7 @@ class IngredientNode(DjangoObjectType):
|
|||
}
|
||||
|
||||
|
||||
class Query(object):
|
||||
class Query:
|
||||
category = Node.Field(CategoryNode)
|
||||
all_categories = DjangoFilterConnectionField(CategoryNode)
|
||||
|
||||
|
|
|
@ -1,36 +1,69 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9 on 2015-12-04 18:20
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('ingredients', '0001_initial'),
|
||||
("ingredients", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Recipe',
|
||||
name="Recipe",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('title', models.CharField(max_length=100)),
|
||||
('instructions', models.TextField()),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("title", models.CharField(max_length=100)),
|
||||
("instructions", models.TextField()),
|
||||
],
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='RecipeIngredient',
|
||||
name="RecipeIngredient",
|
||||
fields=[
|
||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('amount', models.FloatField()),
|
||||
('unit', models.CharField(choices=[('kg', 'Kilograms'), ('l', 'Litres'), ('', 'Units')], max_length=20)),
|
||||
('ingredient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='used_by', to='ingredients.Ingredient')),
|
||||
('recipes', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='amounts', to='recipes.Recipe')),
|
||||
(
|
||||
"id",
|
||||
models.AutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("amount", models.FloatField()),
|
||||
(
|
||||
"unit",
|
||||
models.CharField(
|
||||
choices=[("kg", "Kilograms"), ("l", "Litres"), ("", "Units")],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
(
|
||||
"ingredient",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="used_by",
|
||||
to="ingredients.Ingredient",
|
||||
),
|
||||
),
|
||||
(
|
||||
"recipes",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="amounts",
|
||||
to="recipes.Recipe",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
|
@ -1,25 +1,30 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Generated by Django 1.9 on 2016-11-04 01:06
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('recipes', '0001_initial'),
|
||||
("recipes", "0001_initial"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RenameField(
|
||||
model_name='recipeingredient',
|
||||
old_name='recipes',
|
||||
new_name='recipe',
|
||||
model_name="recipeingredient",
|
||||
old_name="recipes",
|
||||
new_name="recipe",
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='recipeingredient',
|
||||
name='unit',
|
||||
field=models.CharField(choices=[(b'unit', b'Units'), (b'kg', b'Kilograms'), (b'l', b'Litres'), (b'st', b'Shots')], max_length=20),
|
||||
model_name="recipeingredient",
|
||||
name="unit",
|
||||
field=models.CharField(
|
||||
choices=[
|
||||
(b"unit", b"Units"),
|
||||
(b"kg", b"Kilograms"),
|
||||
(b"l", b"Litres"),
|
||||
(b"st", b"Shots"),
|
||||
],
|
||||
max_length=20,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -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,13 +1,15 @@
|
|||
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:
|
||||
model = Recipe
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = ["title", "amounts"]
|
||||
|
||||
|
||||
|
@ -16,6 +18,7 @@ class RecipeIngredientNode(DjangoObjectType):
|
|||
model = RecipeIngredient
|
||||
# Allow for some more advanced filtering here
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = {
|
||||
"ingredient__name": ["exact", "icontains", "istartswith"],
|
||||
"recipe": ["exact"],
|
||||
|
@ -23,7 +26,7 @@ class RecipeIngredientNode(DjangoObjectType):
|
|||
}
|
||||
|
||||
|
||||
class Query(object):
|
||||
class Query:
|
||||
recipe = Node.Field(RecipeNode)
|
||||
all_recipes = DjangoFilterConnectionField(RecipeNode)
|
||||
|
||||
|
|
|
@ -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)),
|
||||
|
|
|
@ -1 +1,302 @@
|
|||
[{"model": "auth.user", "pk": 1, "fields": {"password": "pbkdf2_sha256$24000$0SgBlSlnbv5c$ijVQipm2aNDlcrTL8Qi3SVNHphTm4HIsDfUi4kn9tog=", "last_login": "2016-11-04T00:46:58Z", "is_superuser": true, "username": "admin", "first_name": "", "last_name": "", "email": "asdf@example.com", "is_staff": true, "is_active": true, "date_joined": "2016-11-03T18:24:40Z", "groups": [], "user_permissions": []}}, {"model": "recipes.recipe", "pk": 1, "fields": {"title": "Cheerios With a Shot of Vermouth", "instructions": "https://xkcd.com/720/"}}, {"model": "recipes.recipe", "pk": 2, "fields": {"title": "Quail Eggs in Whipped Cream and MSG", "instructions": "https://xkcd.com/720/"}}, {"model": "recipes.recipe", "pk": 3, "fields": {"title": "Deep Fried Skittles", "instructions": "https://xkcd.com/720/"}}, {"model": "recipes.recipe", "pk": 4, "fields": {"title": "Newt ala Doritos", "instructions": "https://xkcd.com/720/"}}, {"model": "recipes.recipe", "pk": 5, "fields": {"title": "Fruit Salad", "instructions": "Chop up and add together"}}, {"model": "recipes.recipeingredient", "pk": 1, "fields": {"recipes": 5, "ingredient": 9, "amount": 1.0, "unit": "unit"}}, {"model": "recipes.recipeingredient", "pk": 2, "fields": {"recipes": 5, "ingredient": 10, "amount": 2.0, "unit": "unit"}}, {"model": "recipes.recipeingredient", "pk": 3, "fields": {"recipes": 5, "ingredient": 7, "amount": 3.0, "unit": "unit"}}, {"model": "recipes.recipeingredient", "pk": 4, "fields": {"recipes": 5, "ingredient": 8, "amount": 4.0, "unit": "unit"}}, {"model": "recipes.recipeingredient", "pk": 5, "fields": {"recipes": 4, "ingredient": 5, "amount": 1.0, "unit": "kg"}}, {"model": "recipes.recipeingredient", "pk": 6, "fields": {"recipes": 4, "ingredient": 6, "amount": 2.0, "unit": "l"}}, {"model": "recipes.recipeingredient", "pk": 7, "fields": {"recipes": 3, "ingredient": 4, "amount": 1.0, "unit": "unit"}}, {"model": "recipes.recipeingredient", "pk": 8, "fields": {"recipes": 2, "ingredient": 2, "amount": 1.0, "unit": "kg"}}, {"model": "recipes.recipeingredient", "pk": 9, "fields": {"recipes": 2, "ingredient": 11, "amount": 2.0, "unit": "l"}}, {"model": "recipes.recipeingredient", "pk": 10, "fields": {"recipes": 2, "ingredient": 12, "amount": 3.0, "unit": "st"}}, {"model": "recipes.recipeingredient", "pk": 11, "fields": {"recipes": 1, "ingredient": 1, "amount": 1.0, "unit": "kg"}}, {"model": "recipes.recipeingredient", "pk": 12, "fields": {"recipes": 1, "ingredient": 3, "amount": 1.0, "unit": "st"}}, {"model": "ingredients.category", "pk": 1, "fields": {"name": "fruit"}}, {"model": "ingredients.category", "pk": 3, "fields": {"name": "xkcd"}}, {"model": "ingredients.ingredient", "pk": 1, "fields": {"name": "Cheerios", "notes": "this is a note", "category": 3}}, {"model": "ingredients.ingredient", "pk": 2, "fields": {"name": "Quail Eggs", "notes": "has more notes", "category": 3}}, {"model": "ingredients.ingredient", "pk": 3, "fields": {"name": "Vermouth", "notes": "", "category": 3}}, {"model": "ingredients.ingredient", "pk": 4, "fields": {"name": "Skittles", "notes": "", "category": 3}}, {"model": "ingredients.ingredient", "pk": 5, "fields": {"name": "Newt", "notes": "Braised and Confuesd", "category": 3}}, {"model": "ingredients.ingredient", "pk": 6, "fields": {"name": "Doritos", "notes": "Crushed", "category": 3}}, {"model": "ingredients.ingredient", "pk": 7, "fields": {"name": "Apple", "notes": "", "category": 1}}, {"model": "ingredients.ingredient", "pk": 8, "fields": {"name": "Orange", "notes": "", "category": 1}}, {"model": "ingredients.ingredient", "pk": 9, "fields": {"name": "Banana", "notes": "", "category": 1}}, {"model": "ingredients.ingredient", "pk": 10, "fields": {"name": "Grapes", "notes": "", "category": 1}}, {"model": "ingredients.ingredient", "pk": 11, "fields": {"name": "Whipped Cream", "notes": "", "category": 3}}, {"model": "ingredients.ingredient", "pk": 12, "fields": {"name": "MSG", "notes": "", "category": 3}}]
|
||||
[
|
||||
{
|
||||
"fields": {
|
||||
"date_joined": "2016-11-03T18:24:40Z",
|
||||
"email": "asdf@example.com",
|
||||
"first_name": "",
|
||||
"groups": [],
|
||||
"is_active": true,
|
||||
"is_staff": true,
|
||||
"is_superuser": true,
|
||||
"last_login": "2016-11-04T00:46:58Z",
|
||||
"last_name": "",
|
||||
"password": "pbkdf2_sha256$24000$0SgBlSlnbv5c$ijVQipm2aNDlcrTL8Qi3SVNHphTm4HIsDfUi4kn9tog=",
|
||||
"user_permissions": [],
|
||||
"username": "admin"
|
||||
},
|
||||
"model": "auth.user",
|
||||
"pk": 1
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"instructions": "https://xkcd.com/720/",
|
||||
"title": "Cheerios With a Shot of Vermouth"
|
||||
},
|
||||
"model": "recipes.recipe",
|
||||
"pk": 1
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"instructions": "https://xkcd.com/720/",
|
||||
"title": "Quail Eggs in Whipped Cream and MSG"
|
||||
},
|
||||
"model": "recipes.recipe",
|
||||
"pk": 2
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"instructions": "https://xkcd.com/720/",
|
||||
"title": "Deep Fried Skittles"
|
||||
},
|
||||
"model": "recipes.recipe",
|
||||
"pk": 3
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"instructions": "https://xkcd.com/720/",
|
||||
"title": "Newt ala Doritos"
|
||||
},
|
||||
"model": "recipes.recipe",
|
||||
"pk": 4
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"instructions": "Chop up and add together",
|
||||
"title": "Fruit Salad"
|
||||
},
|
||||
"model": "recipes.recipe",
|
||||
"pk": 5
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"amount": 1.0,
|
||||
"ingredient": 9,
|
||||
"recipes": 5,
|
||||
"unit": "unit"
|
||||
},
|
||||
"model": "recipes.recipeingredient",
|
||||
"pk": 1
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"amount": 2.0,
|
||||
"ingredient": 10,
|
||||
"recipes": 5,
|
||||
"unit": "unit"
|
||||
},
|
||||
"model": "recipes.recipeingredient",
|
||||
"pk": 2
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"amount": 3.0,
|
||||
"ingredient": 7,
|
||||
"recipes": 5,
|
||||
"unit": "unit"
|
||||
},
|
||||
"model": "recipes.recipeingredient",
|
||||
"pk": 3
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"amount": 4.0,
|
||||
"ingredient": 8,
|
||||
"recipes": 5,
|
||||
"unit": "unit"
|
||||
},
|
||||
"model": "recipes.recipeingredient",
|
||||
"pk": 4
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"amount": 1.0,
|
||||
"ingredient": 5,
|
||||
"recipes": 4,
|
||||
"unit": "kg"
|
||||
},
|
||||
"model": "recipes.recipeingredient",
|
||||
"pk": 5
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"amount": 2.0,
|
||||
"ingredient": 6,
|
||||
"recipes": 4,
|
||||
"unit": "l"
|
||||
},
|
||||
"model": "recipes.recipeingredient",
|
||||
"pk": 6
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"amount": 1.0,
|
||||
"ingredient": 4,
|
||||
"recipes": 3,
|
||||
"unit": "unit"
|
||||
},
|
||||
"model": "recipes.recipeingredient",
|
||||
"pk": 7
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"amount": 1.0,
|
||||
"ingredient": 2,
|
||||
"recipes": 2,
|
||||
"unit": "kg"
|
||||
},
|
||||
"model": "recipes.recipeingredient",
|
||||
"pk": 8
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"amount": 2.0,
|
||||
"ingredient": 11,
|
||||
"recipes": 2,
|
||||
"unit": "l"
|
||||
},
|
||||
"model": "recipes.recipeingredient",
|
||||
"pk": 9
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"amount": 3.0,
|
||||
"ingredient": 12,
|
||||
"recipes": 2,
|
||||
"unit": "st"
|
||||
},
|
||||
"model": "recipes.recipeingredient",
|
||||
"pk": 10
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"amount": 1.0,
|
||||
"ingredient": 1,
|
||||
"recipes": 1,
|
||||
"unit": "kg"
|
||||
},
|
||||
"model": "recipes.recipeingredient",
|
||||
"pk": 11
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"amount": 1.0,
|
||||
"ingredient": 3,
|
||||
"recipes": 1,
|
||||
"unit": "st"
|
||||
},
|
||||
"model": "recipes.recipeingredient",
|
||||
"pk": 12
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"name": "fruit"
|
||||
},
|
||||
"model": "ingredients.category",
|
||||
"pk": 1
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"name": "xkcd"
|
||||
},
|
||||
"model": "ingredients.category",
|
||||
"pk": 3
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"category": 3,
|
||||
"name": "Cheerios",
|
||||
"notes": "this is a note"
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 1
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"category": 3,
|
||||
"name": "Quail Eggs",
|
||||
"notes": "has more notes"
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 2
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"category": 3,
|
||||
"name": "Vermouth",
|
||||
"notes": ""
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 3
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"category": 3,
|
||||
"name": "Skittles",
|
||||
"notes": ""
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 4
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"category": 3,
|
||||
"name": "Newt",
|
||||
"notes": "Braised and Confused"
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 5
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"category": 3,
|
||||
"name": "Doritos",
|
||||
"notes": "Crushed"
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 6
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"category": 1,
|
||||
"name": "Apple",
|
||||
"notes": ""
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 7
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"category": 1,
|
||||
"name": "Orange",
|
||||
"notes": ""
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 8
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"category": 1,
|
||||
"name": "Banana",
|
||||
"notes": ""
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 9
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"category": 1,
|
||||
"name": "Grapes",
|
||||
"notes": ""
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 10
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"category": 3,
|
||||
"name": "Whipped Cream",
|
||||
"notes": ""
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 11
|
||||
},
|
||||
{
|
||||
"fields": {
|
||||
"category": 3,
|
||||
"name": "MSG",
|
||||
"notes": ""
|
||||
},
|
||||
"model": "ingredients.ingredient",
|
||||
"pk": 12
|
||||
}
|
||||
]
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
graphene>=2.1,<3
|
||||
graphene-django>=2.1,<3
|
||||
graphql-core>=2.1,<3
|
||||
django==3.0.7
|
||||
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,3 +28,5 @@ TEMPLATES = [
|
|||
GRAPHENE = {"SCHEMA": "graphene_django.tests.schema_view.schema"}
|
||||
|
||||
ROOT_URLCONF = "graphene_django.tests.urls"
|
||||
|
||||
USE_TZ = True
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -1,5 +1,3 @@
|
|||
from __future__ import absolute_import
|
||||
|
||||
from django.db import models
|
||||
|
||||
|
||||
|
|
|
@ -1,17 +1,20 @@
|
|||
import graphene
|
||||
from graphene import Schema, relay, resolve_only_args
|
||||
from graphene import Schema, relay
|
||||
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):
|
||||
class Meta:
|
||||
model = ShipModel
|
||||
interfaces = (relay.Node,)
|
||||
fields = "__all__"
|
||||
|
||||
@classmethod
|
||||
def get_node(cls, info, id):
|
||||
|
@ -22,12 +25,14 @@ class Ship(DjangoObjectType):
|
|||
class Character(DjangoObjectType):
|
||||
class Meta:
|
||||
model = CharacterModel
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class Faction(DjangoObjectType):
|
||||
class Meta:
|
||||
model = FactionModel
|
||||
interfaces = (relay.Node,)
|
||||
fields = "__all__"
|
||||
|
||||
@classmethod
|
||||
def get_node(cls, info, id):
|
||||
|
@ -57,16 +62,13 @@ class Query(graphene.ObjectType):
|
|||
node = relay.Node.Field()
|
||||
ships = DjangoConnectionField(Ship, description="All the ships.")
|
||||
|
||||
@resolve_only_args
|
||||
def resolve_ships(self):
|
||||
def resolve_ships(self, info):
|
||||
return get_ships()
|
||||
|
||||
@resolve_only_args
|
||||
def resolve_rebels(self):
|
||||
def resolve_rebels(self, info):
|
||||
return get_rebels()
|
||||
|
||||
@resolve_only_args
|
||||
def resolve_empire(self):
|
||||
def resolve_empire(self, info):
|
||||
return get_empire()
|
||||
|
||||
|
||||
|
|
|
@ -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__ = "2.14.0"
|
||||
__version__ = "3.2.3"
|
||||
|
||||
__all__ = [
|
||||
"__version__",
|
||||
"DjangoObjectType",
|
||||
"DjangoListField",
|
||||
"DjangoConnectionField",
|
||||
"bypass_get_queryset",
|
||||
]
|
||||
|
|
|
@ -1,5 +1,16 @@
|
|||
class MissingType(object):
|
||||
pass
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
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 Choices, JSONField
|
||||
|
||||
|
||||
class MissingType:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
|
||||
try:
|
||||
|
@ -8,14 +19,47 @@ try:
|
|||
from django.contrib.postgres.fields import (
|
||||
ArrayField,
|
||||
HStoreField,
|
||||
JSONField as PGJSONField,
|
||||
IntegerRangeField,
|
||||
RangeField,
|
||||
)
|
||||
except ImportError:
|
||||
ArrayField, HStoreField, PGJSONField, RangeField = (MissingType,) * 4
|
||||
IntegerRangeField, HStoreField, RangeField = (MissingType,) * 3
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
try:
|
||||
# JSONField is only available from Django 3.1
|
||||
from django.db.models import JSONField
|
||||
from django.utils.choices import normalize_choices
|
||||
except ImportError:
|
||||
JSONField = MissingType
|
||||
|
||||
def normalize_choices(choices):
|
||||
if isinstance(choices, type) and issubclass(choices, Choices):
|
||||
choices = choices.choices
|
||||
|
||||
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()
|
||||
|
||||
return choices
|
||||
|
|
|
@ -1,11 +1,19 @@
|
|||
from collections import OrderedDict
|
||||
import inspect
|
||||
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,
|
||||
UUID,
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Decimal,
|
||||
Dynamic,
|
||||
Enum,
|
||||
Field,
|
||||
|
@ -14,48 +22,85 @@ from graphene import (
|
|||
List,
|
||||
NonNull,
|
||||
String,
|
||||
UUID,
|
||||
DateTime,
|
||||
Date,
|
||||
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 assert_valid_name
|
||||
|
||||
try:
|
||||
from graphql import assert_name
|
||||
except ImportError:
|
||||
# Support for older versions of graphql
|
||||
from graphql import assert_valid_name as assert_name
|
||||
from graphql.pyutils import register_description
|
||||
|
||||
from .compat import ArrayField, HStoreField, RangeField, normalize_choices
|
||||
from .fields import DjangoConnectionField, DjangoListField
|
||||
from .settings import graphene_settings
|
||||
from .compat import ArrayField, HStoreField, JSONField, PGJSONField, RangeField
|
||||
from .fields import DjangoListField, DjangoConnectionField
|
||||
from .utils import import_single_dispatch
|
||||
from .utils.str_converters import to_const
|
||||
|
||||
singledispatch = import_single_dispatch()
|
||||
|
||||
class BlankValueField(Field):
|
||||
def wrap_resolve(self, parent_resolver):
|
||||
resolver = self.resolver or parent_resolver
|
||||
|
||||
# create custom resolver
|
||||
def blank_field_wrapper(func):
|
||||
@wraps(func)
|
||||
def wrapped_resolver(*args, **kwargs):
|
||||
return_value = func(*args, **kwargs)
|
||||
if return_value == "":
|
||||
return None
|
||||
return return_value
|
||||
|
||||
return wrapped_resolver
|
||||
|
||||
return blank_field_wrapper(resolver)
|
||||
|
||||
|
||||
class EnumValueField(BlankValueField):
|
||||
def wrap_resolve(self, parent_resolver):
|
||||
resolver = super().wrap_resolve(parent_resolver)
|
||||
|
||||
# create custom resolver
|
||||
def enum_field_wrapper(func):
|
||||
@wraps(func)
|
||||
def wrapped_resolver(*args, **kwargs):
|
||||
return_value = func(*args, **kwargs)
|
||||
if isinstance(return_value, models.Choices):
|
||||
return_value = return_value.value
|
||||
return return_value
|
||||
|
||||
return wrapped_resolver
|
||||
|
||||
return enum_field_wrapper(resolver)
|
||||
|
||||
|
||||
def convert_choice_name(name):
|
||||
name = to_const(force_str(name))
|
||||
try:
|
||||
assert_valid_name(name)
|
||||
except AssertionError:
|
||||
assert_name(name)
|
||||
except GraphQLError:
|
||||
name = "A_%s" % name
|
||||
return name
|
||||
|
||||
|
||||
def get_choices(choices):
|
||||
converted_names = []
|
||||
if isinstance(choices, OrderedDict):
|
||||
choices = choices.items()
|
||||
choices = normalize_choices(choices)
|
||||
for value, help_text in choices:
|
||||
if isinstance(help_text, (tuple, list)):
|
||||
for choice in get_choices(help_text):
|
||||
yield choice
|
||||
yield from get_choices(help_text)
|
||||
else:
|
||||
name = convert_choice_name(value)
|
||||
while name in converted_names:
|
||||
name += "_" + str(len(converted_names))
|
||||
converted_names.append(name)
|
||||
description = help_text
|
||||
description = str(
|
||||
help_text
|
||||
) # TODO: translatable description: https://github.com/graphql-python/graphql-core-next/issues/58
|
||||
yield name, value, description
|
||||
|
||||
|
||||
|
@ -64,12 +109,18 @@ def convert_choices_to_named_enum_with_descriptions(name, choices):
|
|||
named_choices = [(c[0], c[1]) for c in choices]
|
||||
named_choices_descriptions = {c[0]: c[2] for c in choices}
|
||||
|
||||
class EnumWithDescriptionsType(object):
|
||||
class EnumWithDescriptionsType:
|
||||
@property
|
||||
def description(self):
|
||||
return named_choices_descriptions[self.name]
|
||||
return str(named_choices_descriptions[self.name])
|
||||
|
||||
return Enum(name, list(named_choices), type=EnumWithDescriptionsType)
|
||||
return_type = Enum(
|
||||
name,
|
||||
list(named_choices),
|
||||
type=EnumWithDescriptionsType,
|
||||
description="An enumeration.", # Temporary fix until https://github.com/graphql-python/graphene/pull/1502 is merged
|
||||
)
|
||||
return return_type
|
||||
|
||||
|
||||
def generate_enum_name(django_model_meta, field):
|
||||
|
@ -79,14 +130,14 @@ def generate_enum_name(django_model_meta, field):
|
|||
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME
|
||||
)
|
||||
name = custom_func(field)
|
||||
elif graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING is True:
|
||||
elif graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V2_NAMING is True:
|
||||
name = to_camel_case(f"{django_model_meta.object_name}_{field.name}")
|
||||
else:
|
||||
name = "{app_label}{object_name}{field_name}Choices".format(
|
||||
app_label=to_camel_case(django_model_meta.app_label.title()),
|
||||
object_name=django_model_meta.object_name,
|
||||
field_name=to_camel_case(field.name.title()),
|
||||
)
|
||||
else:
|
||||
name = to_camel_case("{}_{}".format(django_model_meta.object_name, field.name))
|
||||
return name
|
||||
|
||||
|
||||
|
@ -98,17 +149,24 @@ 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:
|
||||
enum = convert_choice_field_to_enum(field)
|
||||
EnumCls = convert_choice_field_to_enum(field)
|
||||
required = not (field.blank or field.null)
|
||||
converted = enum(description=field.help_text, required=required)
|
||||
|
||||
converted = EnumCls(
|
||||
description=get_django_field_description(field), required=required
|
||||
).mount_as(EnumValueField)
|
||||
else:
|
||||
converted = convert_django_field(field, registry)
|
||||
if registry is not None:
|
||||
|
@ -116,10 +174,14 @@ def convert_django_field_with_choices(
|
|||
return converted
|
||||
|
||||
|
||||
def get_django_field_description(field):
|
||||
return str(field.help_text) if field.help_text else None
|
||||
|
||||
|
||||
@singledispatch
|
||||
def convert_django_field(field, registry=None):
|
||||
raise Exception(
|
||||
"Don't know how to convert the Django field %s (%s)" % (field, field.__class__)
|
||||
f"Don't know how to convert the Django field {field} ({field.__class__})"
|
||||
)
|
||||
|
||||
|
||||
|
@ -132,62 +194,88 @@ def convert_django_field(field, registry=None):
|
|||
@convert_django_field.register(models.FileField)
|
||||
@convert_django_field.register(models.FilePathField)
|
||||
def convert_field_to_string(field, registry=None):
|
||||
return String(description=field.help_text, required=not field.null)
|
||||
return String(
|
||||
description=get_django_field_description(field), required=not field.null
|
||||
)
|
||||
|
||||
|
||||
@convert_django_field.register(models.AutoField)
|
||||
@convert_django_field.register(models.BigAutoField)
|
||||
@convert_django_field.register(models.SmallAutoField)
|
||||
def convert_field_to_id(field, registry=None):
|
||||
return ID(description=field.help_text, required=not field.null)
|
||||
return ID(description=get_django_field_description(field), required=not field.null)
|
||||
|
||||
|
||||
@convert_django_field.register(models.UUIDField)
|
||||
def convert_field_to_uuid(field, registry=None):
|
||||
return UUID(description=field.help_text, required=not field.null)
|
||||
return UUID(
|
||||
description=get_django_field_description(field), required=not field.null
|
||||
)
|
||||
|
||||
|
||||
@convert_django_field.register(models.BigIntegerField)
|
||||
def convert_big_int_field(field, registry=None):
|
||||
return BigInt(description=field.help_text, required=not field.null)
|
||||
|
||||
|
||||
@convert_django_field.register(models.PositiveIntegerField)
|
||||
@convert_django_field.register(models.PositiveSmallIntegerField)
|
||||
@convert_django_field.register(models.SmallIntegerField)
|
||||
@convert_django_field.register(models.BigIntegerField)
|
||||
@convert_django_field.register(models.IntegerField)
|
||||
def convert_field_to_int(field, registry=None):
|
||||
return Int(description=field.help_text, required=not field.null)
|
||||
return Int(description=get_django_field_description(field), required=not field.null)
|
||||
|
||||
|
||||
@convert_django_field.register(models.NullBooleanField)
|
||||
@convert_django_field.register(models.BooleanField)
|
||||
def convert_field_to_boolean(field, registry=None):
|
||||
return Boolean(description=field.help_text, required=not field.null)
|
||||
return Boolean(
|
||||
description=get_django_field_description(field), required=not field.null
|
||||
)
|
||||
|
||||
|
||||
@convert_django_field.register(models.DecimalField)
|
||||
def convert_field_to_decimal(field, registry=None):
|
||||
return Decimal(description=field.help_text, required=not field.null)
|
||||
return Decimal(
|
||||
description=get_django_field_description(field), required=not field.null
|
||||
)
|
||||
|
||||
|
||||
@convert_django_field.register(models.FloatField)
|
||||
@convert_django_field.register(models.DurationField)
|
||||
def convert_field_to_float(field, registry=None):
|
||||
return Float(description=field.help_text, required=not field.null)
|
||||
return Float(
|
||||
description=get_django_field_description(field), required=not field.null
|
||||
)
|
||||
|
||||
|
||||
@convert_django_field.register(models.DateTimeField)
|
||||
def convert_datetime_to_string(field, registry=None):
|
||||
return DateTime(description=field.help_text, required=not field.null)
|
||||
return DateTime(
|
||||
description=get_django_field_description(field), required=not field.null
|
||||
)
|
||||
|
||||
|
||||
@convert_django_field.register(models.DateField)
|
||||
def convert_date_to_string(field, registry=None):
|
||||
return Date(description=field.help_text, required=not field.null)
|
||||
return Date(
|
||||
description=get_django_field_description(field), required=not field.null
|
||||
)
|
||||
|
||||
|
||||
@convert_django_field.register(models.TimeField)
|
||||
def convert_time_to_string(field, registry=None):
|
||||
return Time(description=field.help_text, required=not field.null)
|
||||
return Time(
|
||||
description=get_django_field_description(field), required=not field.null
|
||||
)
|
||||
|
||||
|
||||
@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():
|
||||
|
@ -195,10 +283,55 @@ def convert_onetoone_field_to_djangomodel(field, registry=None):
|
|||
if not _type:
|
||||
return
|
||||
|
||||
# We do this for a bug in Django 1.8, where null attr
|
||||
# is not available in the OneToOneRel instance
|
||||
null = getattr(field, "null", True)
|
||||
return Field(_type, required=not 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)
|
||||
|
||||
|
@ -214,11 +347,10 @@ def convert_field_to_list_or_connection(field, registry=None):
|
|||
if not _type:
|
||||
return
|
||||
|
||||
description = (
|
||||
field.help_text
|
||||
if isinstance(field, models.ManyToManyField)
|
||||
else field.field.help_text
|
||||
)
|
||||
if isinstance(field, models.ManyToManyField):
|
||||
description = get_django_field_description(field)
|
||||
else:
|
||||
description = get_django_field_description(field.field)
|
||||
|
||||
# If there is a connection, we should transform the field
|
||||
# into a DjangoConnectionField
|
||||
|
@ -247,6 +379,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():
|
||||
|
@ -254,7 +390,83 @@ def convert_field_to_djangomodel(field, registry=None):
|
|||
if not _type:
|
||||
return
|
||||
|
||||
return Field(_type, description=field.help_text, 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 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,
|
||||
)
|
||||
|
||||
return Dynamic(dynamic_type)
|
||||
|
||||
|
@ -268,14 +480,19 @@ def convert_postgres_array_to_list(field, registry=None):
|
|||
if inner_type.kwargs["required"]
|
||||
else type(inner_type)
|
||||
)
|
||||
return List(inner_type, description=field.help_text, required=not field.null)
|
||||
return List(
|
||||
inner_type,
|
||||
description=get_django_field_description(field),
|
||||
required=not field.null,
|
||||
)
|
||||
|
||||
|
||||
@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):
|
||||
return JSONString(description=field.help_text, required=not field.null)
|
||||
@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
|
||||
)
|
||||
|
||||
|
||||
@convert_django_field.register(RangeField)
|
||||
|
@ -287,4 +504,13 @@ def convert_postgres_range_to_string(field, registry=None):
|
|||
if inner_type.kwargs["required"]
|
||||
else type(inner_type)
|
||||
)
|
||||
return List(inner_type, description=field.help_text, required=not field.null)
|
||||
return List(
|
||||
inner_type,
|
||||
description=get_django_field_description(field),
|
||||
required=not field.null,
|
||||
)
|
||||
|
||||
|
||||
# Register Django lazy()-wrapped values as GraphQL description/help_text.
|
||||
# This is needed for using lazy translations, see https://github.com/graphql-python/graphql-core-next/issues/58.
|
||||
register_description(Promise)
|
||||
|
|
0
graphene_django/debug/exception/__init__.py
Normal file
0
graphene_django/debug/exception/__init__.py
Normal file
17
graphene_django/debug/exception/formating.py
Normal file
17
graphene_django/debug/exception/formating.py
Normal file
|
@ -0,0 +1,17 @@
|
|||
import traceback
|
||||
|
||||
from django.utils.encoding import force_str
|
||||
|
||||
from .types import DjangoDebugException
|
||||
|
||||
|
||||
def wrap_exception(exception):
|
||||
return DjangoDebugException(
|
||||
message=force_str(exception),
|
||||
exc_type=force_str(type(exception)),
|
||||
stack="".join(
|
||||
traceback.format_exception(
|
||||
exception, value=exception, tb=exception.__traceback__
|
||||
)
|
||||
),
|
||||
)
|
10
graphene_django/debug/exception/types.py
Normal file
10
graphene_django/debug/exception/types.py
Normal file
|
@ -0,0 +1,10 @@
|
|||
from graphene import ObjectType, String
|
||||
|
||||
|
||||
class DjangoDebugException(ObjectType):
|
||||
class Meta:
|
||||
description = "Represents a single exception raised."
|
||||
|
||||
exc_type = String(required=True, description="The class of the exception")
|
||||
message = String(required=True, description="The message of the exception")
|
||||
stack = String(required=True, description="The stack trace")
|
|
@ -1,34 +1,38 @@
|
|||
from django.db import connections
|
||||
|
||||
from promise import Promise
|
||||
|
||||
from .exception.formating import wrap_exception
|
||||
from .sql.tracking import unwrap_cursor, wrap_cursor
|
||||
from .types import DjangoDebug
|
||||
|
||||
|
||||
class DjangoDebugContext(object):
|
||||
class DjangoDebugContext:
|
||||
def __init__(self):
|
||||
self.debug_promise = None
|
||||
self.promises = []
|
||||
self.debug_result = None
|
||||
self.results = []
|
||||
self.object = DjangoDebug(sql=[], exceptions=[])
|
||||
self.enable_instrumentation()
|
||||
self.object = DjangoDebug(sql=[])
|
||||
|
||||
def get_debug_promise(self):
|
||||
if not self.debug_promise:
|
||||
self.debug_promise = Promise.all(self.promises)
|
||||
self.promises = []
|
||||
return self.debug_promise.then(self.on_resolve_all_promises)
|
||||
def get_debug_result(self):
|
||||
if not self.debug_result:
|
||||
self.debug_result = self.results
|
||||
self.results = []
|
||||
return self.on_resolve_all_results()
|
||||
|
||||
def on_resolve_all_promises(self, values):
|
||||
if self.promises:
|
||||
self.debug_promise = None
|
||||
return self.get_debug_promise()
|
||||
def on_resolve_error(self, value):
|
||||
if hasattr(self, "object"):
|
||||
self.object.exceptions.append(wrap_exception(value))
|
||||
return value
|
||||
|
||||
def on_resolve_all_results(self):
|
||||
if self.results:
|
||||
self.debug_result = None
|
||||
return self.get_debug_result()
|
||||
self.disable_instrumentation()
|
||||
return self.object
|
||||
|
||||
def add_promise(self, promise):
|
||||
if self.debug_promise:
|
||||
self.promises.append(promise)
|
||||
def add_result(self, result):
|
||||
if self.debug_result:
|
||||
self.results.append(result)
|
||||
|
||||
def enable_instrumentation(self):
|
||||
# This is thread-safe because database connections are thread-local.
|
||||
|
@ -40,7 +44,7 @@ class DjangoDebugContext(object):
|
|||
unwrap_cursor(connection)
|
||||
|
||||
|
||||
class DjangoDebugMiddleware(object):
|
||||
class DjangoDebugMiddleware:
|
||||
def resolve(self, next, root, info, **args):
|
||||
context = info.context
|
||||
django_debug = getattr(context, "django_debug", None)
|
||||
|
@ -56,7 +60,10 @@ class DjangoDebugMiddleware(object):
|
|||
)
|
||||
)
|
||||
if info.schema.get_type("DjangoDebug") == info.return_type:
|
||||
return context.django_debug.get_debug_promise()
|
||||
promise = next(root, info, **args)
|
||||
context.django_debug.add_promise(promise)
|
||||
return promise
|
||||
return context.django_debug.get_debug_result()
|
||||
try:
|
||||
result = next(root, info, **args)
|
||||
except Exception as e:
|
||||
return context.django_debug.on_resolve_error(e)
|
||||
context.django_debug.add_result(result)
|
||||
return result
|
||||
|
|
|
@ -1,11 +1,9 @@
|
|||
# Code obtained from django-debug-toolbar sql panel tracking
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import json
|
||||
from threading import local
|
||||
from time import time
|
||||
|
||||
import six
|
||||
from django.utils.encoding import force_str
|
||||
|
||||
from .types import DjangoDebugSQL
|
||||
|
@ -51,7 +49,7 @@ def unwrap_cursor(connection):
|
|||
del connection._graphene_cursor
|
||||
|
||||
|
||||
class ExceptionCursorWrapper(object):
|
||||
class ExceptionCursorWrapper:
|
||||
"""
|
||||
Wraps a cursor and raises an exception on any operation.
|
||||
Used in Templates panel.
|
||||
|
@ -64,7 +62,7 @@ class ExceptionCursorWrapper(object):
|
|||
raise SQLQueryTriggered()
|
||||
|
||||
|
||||
class NormalCursorWrapper(object):
|
||||
class NormalCursorWrapper:
|
||||
"""
|
||||
Wraps a cursor and logs queries.
|
||||
"""
|
||||
|
@ -77,7 +75,7 @@ class NormalCursorWrapper(object):
|
|||
self.logger = logger
|
||||
|
||||
def _quote_expr(self, element):
|
||||
if isinstance(element, six.string_types):
|
||||
if isinstance(element, str):
|
||||
return "'%s'" % force_str(element).replace("'", "''")
|
||||
else:
|
||||
return repr(element)
|
||||
|
@ -86,7 +84,7 @@ class NormalCursorWrapper(object):
|
|||
if not params:
|
||||
return params
|
||||
if isinstance(params, dict):
|
||||
return dict((key, self._quote_expr(value)) for key, value in params.items())
|
||||
return {key: self._quote_expr(value) for key, value in params.items()}
|
||||
return list(map(self._quote_expr, params))
|
||||
|
||||
def _decode(self, param):
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import graphene
|
||||
import pytest
|
||||
|
||||
import graphene
|
||||
from graphene.relay import Node
|
||||
from graphene_django import DjangoConnectionField, DjangoObjectType
|
||||
|
||||
|
@ -8,7 +9,7 @@ from ..middleware import DjangoDebugMiddleware
|
|||
from ..types import DjangoDebug
|
||||
|
||||
|
||||
class context(object):
|
||||
class context:
|
||||
pass
|
||||
|
||||
|
||||
|
@ -22,10 +23,11 @@ def test_should_query_field():
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
reporter = graphene.Field(ReporterType)
|
||||
debug = graphene.Field(DjangoDebug, name="__debug")
|
||||
debug = graphene.Field(DjangoDebug, name="_debug")
|
||||
|
||||
def resolve_reporter(self, info, **args):
|
||||
return Reporter.objects.first()
|
||||
|
@ -35,7 +37,7 @@ def test_should_query_field():
|
|||
reporter {
|
||||
lastName
|
||||
}
|
||||
__debug {
|
||||
_debug {
|
||||
sql {
|
||||
rawSql
|
||||
}
|
||||
|
@ -44,9 +46,7 @@ def test_should_query_field():
|
|||
"""
|
||||
expected = {
|
||||
"reporter": {"lastName": "ABA"},
|
||||
"__debug": {
|
||||
"sql": [{"rawSql": str(Reporter.objects.order_by("pk")[:1].query)}]
|
||||
},
|
||||
"_debug": {"sql": [{"rawSql": str(Reporter.objects.order_by("pk")[:1].query)}]},
|
||||
}
|
||||
schema = graphene.Schema(query=Query)
|
||||
result = schema.execute(
|
||||
|
@ -71,10 +71,11 @@ def test_should_query_nested_field(graphene_settings, max_limit):
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
reporter = graphene.Field(ReporterType)
|
||||
debug = graphene.Field(DjangoDebug, name="__debug")
|
||||
debug = graphene.Field(DjangoDebug, name="_debug")
|
||||
|
||||
def resolve_reporter(self, info, **args):
|
||||
return Reporter.objects.first()
|
||||
|
@ -88,7 +89,7 @@ def test_should_query_nested_field(graphene_settings, max_limit):
|
|||
pets { edges { node { lastName } } }
|
||||
} } }
|
||||
}
|
||||
__debug {
|
||||
_debug {
|
||||
sql {
|
||||
rawSql
|
||||
}
|
||||
|
@ -116,12 +117,12 @@ def test_should_query_nested_field(graphene_settings, max_limit):
|
|||
)
|
||||
assert not result.errors
|
||||
query = str(Reporter.objects.order_by("pk")[:1].query)
|
||||
assert result.data["__debug"]["sql"][0]["rawSql"] == query
|
||||
assert "COUNT" in result.data["__debug"]["sql"][1]["rawSql"]
|
||||
assert "tests_reporter_pets" in result.data["__debug"]["sql"][2]["rawSql"]
|
||||
assert "COUNT" in result.data["__debug"]["sql"][3]["rawSql"]
|
||||
assert "tests_reporter_pets" in result.data["__debug"]["sql"][4]["rawSql"]
|
||||
assert len(result.data["__debug"]["sql"]) == 5
|
||||
assert result.data["_debug"]["sql"][0]["rawSql"] == query
|
||||
assert "COUNT" in result.data["_debug"]["sql"][1]["rawSql"]
|
||||
assert "tests_reporter_pets" in result.data["_debug"]["sql"][2]["rawSql"]
|
||||
assert "COUNT" in result.data["_debug"]["sql"][3]["rawSql"]
|
||||
assert "tests_reporter_pets" in result.data["_debug"]["sql"][4]["rawSql"]
|
||||
assert len(result.data["_debug"]["sql"]) == 5
|
||||
|
||||
assert result.data["reporter"] == expected["reporter"]
|
||||
|
||||
|
@ -136,10 +137,11 @@ def test_should_query_list():
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
all_reporters = graphene.List(ReporterType)
|
||||
debug = graphene.Field(DjangoDebug, name="__debug")
|
||||
debug = graphene.Field(DjangoDebug, name="_debug")
|
||||
|
||||
def resolve_all_reporters(self, info, **args):
|
||||
return Reporter.objects.all()
|
||||
|
@ -149,7 +151,7 @@ def test_should_query_list():
|
|||
allReporters {
|
||||
lastName
|
||||
}
|
||||
__debug {
|
||||
_debug {
|
||||
sql {
|
||||
rawSql
|
||||
}
|
||||
|
@ -158,7 +160,7 @@ def test_should_query_list():
|
|||
"""
|
||||
expected = {
|
||||
"allReporters": [{"lastName": "ABA"}, {"lastName": "Griffin"}],
|
||||
"__debug": {"sql": [{"rawSql": str(Reporter.objects.all().query)}]},
|
||||
"_debug": {"sql": [{"rawSql": str(Reporter.objects.all().query)}]},
|
||||
}
|
||||
schema = graphene.Schema(query=Query)
|
||||
result = schema.execute(
|
||||
|
@ -181,10 +183,11 @@ def test_should_query_connection(graphene_settings, max_limit):
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
all_reporters = DjangoConnectionField(ReporterType)
|
||||
debug = graphene.Field(DjangoDebug, name="__debug")
|
||||
debug = graphene.Field(DjangoDebug, name="_debug")
|
||||
|
||||
def resolve_all_reporters(self, info, **args):
|
||||
return Reporter.objects.all()
|
||||
|
@ -198,7 +201,7 @@ def test_should_query_connection(graphene_settings, max_limit):
|
|||
}
|
||||
}
|
||||
}
|
||||
__debug {
|
||||
_debug {
|
||||
sql {
|
||||
rawSql
|
||||
}
|
||||
|
@ -212,10 +215,10 @@ def test_should_query_connection(graphene_settings, max_limit):
|
|||
)
|
||||
assert not result.errors
|
||||
assert result.data["allReporters"] == expected["allReporters"]
|
||||
assert len(result.data["__debug"]["sql"]) == 2
|
||||
assert "COUNT" in result.data["__debug"]["sql"][0]["rawSql"]
|
||||
assert len(result.data["_debug"]["sql"]) == 2
|
||||
assert "COUNT" in result.data["_debug"]["sql"][0]["rawSql"]
|
||||
query = str(Reporter.objects.all()[:1].query)
|
||||
assert result.data["__debug"]["sql"][1]["rawSql"] == query
|
||||
assert result.data["_debug"]["sql"][1]["rawSql"] == query
|
||||
|
||||
|
||||
@pytest.mark.parametrize("max_limit", [None, 100])
|
||||
|
@ -233,11 +236,12 @@ def test_should_query_connectionfilter(graphene_settings, max_limit):
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
all_reporters = DjangoFilterConnectionField(ReporterType, fields=["last_name"])
|
||||
s = graphene.String(resolver=lambda *_: "S")
|
||||
debug = graphene.Field(DjangoDebug, name="__debug")
|
||||
debug = graphene.Field(DjangoDebug, name="_debug")
|
||||
|
||||
def resolve_all_reporters(self, info, **args):
|
||||
return Reporter.objects.all()
|
||||
|
@ -251,7 +255,7 @@ def test_should_query_connectionfilter(graphene_settings, max_limit):
|
|||
}
|
||||
}
|
||||
}
|
||||
__debug {
|
||||
_debug {
|
||||
sql {
|
||||
rawSql
|
||||
}
|
||||
|
@ -265,7 +269,46 @@ def test_should_query_connectionfilter(graphene_settings, max_limit):
|
|||
)
|
||||
assert not result.errors
|
||||
assert result.data["allReporters"] == expected["allReporters"]
|
||||
assert len(result.data["__debug"]["sql"]) == 2
|
||||
assert "COUNT" in result.data["__debug"]["sql"][0]["rawSql"]
|
||||
assert len(result.data["_debug"]["sql"]) == 2
|
||||
assert "COUNT" in result.data["_debug"]["sql"][0]["rawSql"]
|
||||
query = str(Reporter.objects.all()[:1].query)
|
||||
assert result.data["__debug"]["sql"][1]["rawSql"] == query
|
||||
assert result.data["_debug"]["sql"][1]["rawSql"] == query
|
||||
|
||||
|
||||
def test_should_query_stack_trace():
|
||||
class ReporterType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
reporter = graphene.Field(ReporterType)
|
||||
debug = graphene.Field(DjangoDebug, name="_debug")
|
||||
|
||||
def resolve_reporter(self, info, **args):
|
||||
raise Exception("caught stack trace")
|
||||
|
||||
query = """
|
||||
query ReporterQuery {
|
||||
reporter {
|
||||
lastName
|
||||
}
|
||||
_debug {
|
||||
exceptions {
|
||||
message
|
||||
stack
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
schema = graphene.Schema(query=Query)
|
||||
result = schema.execute(
|
||||
query, context_value=context(), middleware=[DjangoDebugMiddleware()]
|
||||
)
|
||||
assert result.errors
|
||||
assert len(result.data["_debug"]["exceptions"])
|
||||
debug_exception = result.data["_debug"]["exceptions"][0]
|
||||
assert debug_exception["stack"].count("\n") > 1
|
||||
assert "test_query.py" in debug_exception["stack"]
|
||||
assert debug_exception["message"] == "caught stack trace"
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
from graphene import List, ObjectType
|
||||
|
||||
from .exception.types import DjangoDebugException
|
||||
from .sql.types import DjangoDebugSQL
|
||||
|
||||
|
||||
|
@ -8,3 +9,6 @@ class DjangoDebug(ObjectType):
|
|||
description = "Debugging information for the current query."
|
||||
|
||||
sql = List(DjangoDebugSQL, description="Executed SQL queries for this API query.")
|
||||
exceptions = List(
|
||||
DjangoDebugException, description="Raise exceptions for this API query."
|
||||
)
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
from functools import partial
|
||||
|
||||
import six
|
||||
from django.db.models.query import QuerySet
|
||||
from graphql_relay.connection.arrayconnection import (
|
||||
connection_from_list_slice,
|
||||
from graphql_relay import (
|
||||
connection_from_array_slice,
|
||||
cursor_to_offset,
|
||||
get_offset_with_default,
|
||||
offset_to_cursor,
|
||||
|
@ -11,7 +10,8 @@ from graphql_relay.connection.arrayconnection import (
|
|||
from promise import Promise
|
||||
|
||||
from graphene import Int, NonNull
|
||||
from graphene.relay import ConnectionField, PageInfo
|
||||
from graphene.relay import ConnectionField
|
||||
from graphene.relay.connection import connection_adapter, page_info_adapter
|
||||
from graphene.types import Field, List
|
||||
|
||||
from .settings import graphene_settings
|
||||
|
@ -20,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(DjangoListField, self).__init__(List(NonNull(_type)), *args, **kwargs)
|
||||
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):
|
||||
|
@ -60,13 +63,17 @@ class DjangoListField(Field):
|
|||
|
||||
return queryset
|
||||
|
||||
def get_resolver(self, parent_resolver):
|
||||
def wrap_resolve(self, parent_resolver):
|
||||
resolver = super().wrap_resolve(parent_resolver)
|
||||
_type = self.type
|
||||
if isinstance(_type, NonNull):
|
||||
_type = _type.of_type
|
||||
django_object_type = _type.of_type.of_type
|
||||
return partial(
|
||||
self.list_resolver, django_object_type, parent_resolver, self.get_manager(),
|
||||
self.list_resolver,
|
||||
django_object_type,
|
||||
resolver,
|
||||
self.get_manager(),
|
||||
)
|
||||
|
||||
|
||||
|
@ -81,7 +88,7 @@ class DjangoConnectionField(ConnectionField):
|
|||
graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST,
|
||||
)
|
||||
kwargs.setdefault("offset", Int())
|
||||
super(DjangoConnectionField, self).__init__(*args, **kwargs)
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
|
@ -143,37 +150,40 @@ class DjangoConnectionField(ConnectionField):
|
|||
iterable = maybe_queryset(iterable)
|
||||
|
||||
if isinstance(iterable, QuerySet):
|
||||
list_length = iterable.count()
|
||||
array_length = iterable.count()
|
||||
else:
|
||||
list_length = len(iterable)
|
||||
list_slice_length = (
|
||||
min(max_limit, list_length) if max_limit is not None else list_length
|
||||
)
|
||||
array_length = len(iterable)
|
||||
|
||||
# If after is higher than list_length, connection_from_list_slice
|
||||
# If after is higher than array_length, connection_from_array_slice
|
||||
# would try to do a negative slicing which makes django throw an
|
||||
# AssertionError
|
||||
after = min(get_offset_with_default(args.get("after"), -1) + 1, list_length)
|
||||
slice_start = min(
|
||||
get_offset_with_default(args.get("after"), -1) + 1,
|
||||
array_length,
|
||||
)
|
||||
array_slice_length = array_length - slice_start
|
||||
|
||||
if max_limit is not None and "first" not in args:
|
||||
if "last" in args:
|
||||
args["first"] = list_length
|
||||
list_slice_length = list_length
|
||||
else:
|
||||
args["first"] = max_limit
|
||||
# Impose the maximum limit via the `first` field if neither first or last are already provided
|
||||
# (note that if any of them is provided they must be under max_limit otherwise an error is raised).
|
||||
if (
|
||||
max_limit is not None
|
||||
and args.get("first", None) is None
|
||||
and args.get("last", None) is None
|
||||
):
|
||||
args["first"] = max_limit
|
||||
|
||||
connection = connection_from_list_slice(
|
||||
iterable[after:],
|
||||
connection = connection_from_array_slice(
|
||||
iterable[slice_start:],
|
||||
args,
|
||||
slice_start=after,
|
||||
list_length=list_length,
|
||||
list_slice_length=list_slice_length,
|
||||
connection_type=connection,
|
||||
slice_start=slice_start,
|
||||
array_length=array_length,
|
||||
array_slice_length=array_slice_length,
|
||||
connection_type=partial(connection_adapter, connection),
|
||||
edge_type=connection.Edge,
|
||||
pageinfo_type=PageInfo,
|
||||
page_info_type=page_info_adapter,
|
||||
)
|
||||
connection.iterable = iterable
|
||||
connection.length = list_length
|
||||
connection.length = array_length
|
||||
return connection
|
||||
|
||||
@classmethod
|
||||
|
@ -187,7 +197,7 @@ class DjangoConnectionField(ConnectionField):
|
|||
enforce_first_or_last,
|
||||
root,
|
||||
info,
|
||||
**args
|
||||
**args,
|
||||
):
|
||||
first = args.get("first")
|
||||
last = args.get("last")
|
||||
|
@ -234,10 +244,10 @@ class DjangoConnectionField(ConnectionField):
|
|||
|
||||
return on_resolve(iterable)
|
||||
|
||||
def get_resolver(self, parent_resolver):
|
||||
def wrap_resolve(self, parent_resolver):
|
||||
return partial(
|
||||
self.connection_resolver,
|
||||
parent_resolver,
|
||||
self.resolver or parent_resolver,
|
||||
self.connection_type,
|
||||
self.get_manager(),
|
||||
self.get_queryset_resolver(),
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import warnings
|
||||
|
||||
from ..utils import DJANGO_FILTER_INSTALLED
|
||||
|
||||
if not DJANGO_FILTER_INSTALLED:
|
||||
|
@ -9,10 +10,21 @@ if not DJANGO_FILTER_INSTALLED:
|
|||
)
|
||||
else:
|
||||
from .fields import DjangoFilterConnectionField
|
||||
from .filterset import GlobalIDFilter, GlobalIDMultipleChoiceFilter
|
||||
from .filters import (
|
||||
ArrayFilter,
|
||||
GlobalIDFilter,
|
||||
GlobalIDMultipleChoiceFilter,
|
||||
ListFilter,
|
||||
RangeFilter,
|
||||
TypedFilter,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DjangoFilterConnectionField",
|
||||
"GlobalIDFilter",
|
||||
"GlobalIDMultipleChoiceFilter",
|
||||
"ArrayFilter",
|
||||
"ListFilter",
|
||||
"RangeFilter",
|
||||
"TypedFilter",
|
||||
]
|
||||
|
|
|
@ -2,22 +2,41 @@ from collections import OrderedDict
|
|||
from functools import partial
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
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
|
||||
from .utils import get_filtering_args_from_filterset, get_filterset_class
|
||||
|
||||
|
||||
def convert_enum(data):
|
||||
"""
|
||||
Check if the data is a enum option (or potentially nested list of enum option)
|
||||
and convert it to its value.
|
||||
|
||||
This method is used to pre-process the data for the filters as they can take an
|
||||
graphene.Enum as argument, but filters (from django_filters) expect a simple value.
|
||||
"""
|
||||
if isinstance(data, list):
|
||||
return [convert_enum(item) for item in data]
|
||||
if isinstance(type(data), EnumType):
|
||||
return data.value
|
||||
else:
|
||||
return data
|
||||
|
||||
|
||||
class DjangoFilterConnectionField(DjangoConnectionField):
|
||||
def __init__(
|
||||
self,
|
||||
type,
|
||||
type_,
|
||||
fields=None,
|
||||
order_by=None,
|
||||
extra_filter_meta=None,
|
||||
filterset_class=None,
|
||||
*args,
|
||||
**kwargs
|
||||
**kwargs,
|
||||
):
|
||||
self._fields = fields
|
||||
self._provided_filterset_class = filterset_class
|
||||
|
@ -25,7 +44,7 @@ class DjangoFilterConnectionField(DjangoConnectionField):
|
|||
self._filtering_args = None
|
||||
self._extra_filter_meta = extra_filter_meta
|
||||
self._base_args = None
|
||||
super(DjangoFilterConnectionField, self).__init__(type, *args, **kwargs)
|
||||
super().__init__(type_, *args, **kwargs)
|
||||
|
||||
@property
|
||||
def args(self):
|
||||
|
@ -39,12 +58,12 @@ 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)
|
||||
|
||||
filterset_class = self._provided_filterset_class or (
|
||||
self.node_type._meta.filterset_class
|
||||
filterset_class = (
|
||||
self._provided_filterset_class or self.node_type._meta.filterset_class
|
||||
)
|
||||
self._filterset_class = get_filterset_class(filterset_class, **meta)
|
||||
|
||||
|
@ -66,19 +85,17 @@ class DjangoFilterConnectionField(DjangoConnectionField):
|
|||
kwargs = {}
|
||||
for k, v in args.items():
|
||||
if k in filtering_args:
|
||||
if k == "order_by":
|
||||
if k == "order_by" and v is not None:
|
||||
v = to_snake_case(v)
|
||||
kwargs[k] = v
|
||||
kwargs[k] = convert_enum(v)
|
||||
return kwargs
|
||||
|
||||
qs = super(DjangoFilterConnectionField, cls).resolve_queryset(
|
||||
connection, iterable, info, args
|
||||
)
|
||||
qs = super().resolve_queryset(connection, iterable, info, args)
|
||||
|
||||
filterset = filterset_class(
|
||||
data=filter_kwargs(), queryset=qs, request=info.context
|
||||
)
|
||||
if filterset.form.is_valid():
|
||||
if filterset.is_valid():
|
||||
return filterset.qs
|
||||
raise ValidationError(filterset.form.errors.as_json())
|
||||
|
||||
|
|
26
graphene_django/filter/filters/__init__.py
Normal file
26
graphene_django/filter/filters/__init__.py
Normal file
|
@ -0,0 +1,26 @@
|
|||
import warnings
|
||||
|
||||
from ...utils import DJANGO_FILTER_INSTALLED
|
||||
|
||||
if not DJANGO_FILTER_INSTALLED:
|
||||
warnings.warn(
|
||||
"Use of django filtering requires the django-filter package "
|
||||
"be installed. You can do so using `pip install django-filter`",
|
||||
ImportWarning,
|
||||
)
|
||||
else:
|
||||
from .array_filter import ArrayFilter
|
||||
from .global_id_filter import GlobalIDFilter, GlobalIDMultipleChoiceFilter
|
||||
from .list_filter import ListFilter
|
||||
from .range_filter import RangeFilter
|
||||
from .typed_filter import TypedFilter
|
||||
|
||||
__all__ = [
|
||||
"DjangoFilterConnectionField",
|
||||
"GlobalIDFilter",
|
||||
"GlobalIDMultipleChoiceFilter",
|
||||
"ArrayFilter",
|
||||
"ListFilter",
|
||||
"RangeFilter",
|
||||
"TypedFilter",
|
||||
]
|
50
graphene_django/filter/filters/array_filter.py
Normal file
50
graphene_django/filter/filters/array_filter.py
Normal file
|
@ -0,0 +1,50 @@
|
|||
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
|
||||
empty or not.
|
||||
This needs to be done as in this case we expect to get the filter applied with
|
||||
an empty list since it's a valid value but django_filter consider an empty list
|
||||
to be an empty input value (see `EMPTY_VALUES`) meaning that
|
||||
the filter does not need to be applied (hence returning the original
|
||||
queryset).
|
||||
"""
|
||||
if value in EMPTY_VALUES and value != []:
|
||||
return qs
|
||||
if self.distinct:
|
||||
qs = qs.distinct()
|
||||
lookup = f"{self.field_name}__{self.lookup_expr}"
|
||||
qs = self.get_method(qs)(**{lookup: value})
|
||||
return qs
|
27
graphene_django/filter/filters/global_id_filter.py
Normal file
27
graphene_django/filter/filters/global_id_filter.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
from django_filters import Filter, MultipleChoiceFilter
|
||||
from graphql_relay.node.node import from_global_id
|
||||
|
||||
from ...forms import GlobalIDFormField, GlobalIDMultipleChoiceField
|
||||
|
||||
|
||||
class GlobalIDFilter(Filter):
|
||||
"""
|
||||
Filter for Relay global ID.
|
||||
"""
|
||||
|
||||
field_class = GlobalIDFormField
|
||||
|
||||
def filter(self, qs, value):
|
||||
"""Convert the filter value to a primary key before filtering"""
|
||||
_id = None
|
||||
if value is not None:
|
||||
_, _id = from_global_id(value)
|
||||
return super().filter(qs, _id)
|
||||
|
||||
|
||||
class GlobalIDMultipleChoiceFilter(MultipleChoiceFilter):
|
||||
field_class = GlobalIDMultipleChoiceField
|
||||
|
||||
def filter(self, qs, value):
|
||||
gids = [from_global_id(v)[1] for v in value]
|
||||
return super().filter(qs, gids)
|
50
graphene_django/filter/filters/list_filter.py
Normal file
50
graphene_django/filter/filters/list_filter.py
Normal file
|
@ -0,0 +1,50 @@
|
|||
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
|
||||
empty or not.
|
||||
This needs to be done as in this case we expect to get an empty output
|
||||
(if not an exclude filter) but django_filter consider an empty list
|
||||
to be an empty input value (see `EMPTY_VALUES`) meaning that
|
||||
the filter does not need to be applied (hence returning the original
|
||||
queryset).
|
||||
"""
|
||||
if value is not None and len(value) == 0:
|
||||
if self.exclude:
|
||||
return qs
|
||||
else:
|
||||
return qs.none()
|
||||
else:
|
||||
return super().filter(qs, value)
|
24
graphene_django/filter/filters/range_filter.py
Normal file
24
graphene_django/filter/filters/range_filter.py
Normal file
|
@ -0,0 +1,24 @@
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.forms import Field
|
||||
|
||||
from .typed_filter import TypedFilter
|
||||
|
||||
|
||||
def validate_range(value):
|
||||
"""
|
||||
Validator for range filter input: the list of value must be of length 2.
|
||||
Note that validators are only run if the value is not empty.
|
||||
"""
|
||||
if len(value) != 2:
|
||||
raise ValidationError(
|
||||
"Invalid range specified: it needs to contain 2 values.", code="invalid"
|
||||
)
|
||||
|
||||
|
||||
class RangeField(Field):
|
||||
default_validators = [validate_range]
|
||||
empty_values = [None]
|
||||
|
||||
|
||||
class RangeFilter(TypedFilter):
|
||||
field_class = RangeField
|
27
graphene_django/filter/filters/typed_filter.py
Normal file
27
graphene_django/filter/filters/typed_filter.py
Normal file
|
@ -0,0 +1,27 @@
|
|||
from django_filters import Filter
|
||||
|
||||
from graphene.types.utils import get_type
|
||||
|
||||
|
||||
class TypedFilter(Filter):
|
||||
"""
|
||||
Filter class for which the input GraphQL type can explicitly be provided.
|
||||
If it is not provided, when building the schema, it will try to guess
|
||||
it from the field.
|
||||
"""
|
||||
|
||||
def __init__(self, input_type=None, *args, **kwargs):
|
||||
self._input_type = input_type
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def input_type(self):
|
||||
input_type = get_type(self._input_type)
|
||||
if input_type is not None:
|
||||
if not callable(getattr(input_type, "get_type", None)):
|
||||
raise ValueError(
|
||||
"Wrong `input_type` for {}: it only accepts graphene types, got {}".format(
|
||||
self.__class__.__name__, input_type
|
||||
)
|
||||
)
|
||||
return input_type
|
|
@ -1,33 +1,13 @@
|
|||
import itertools
|
||||
|
||||
from django.db import models
|
||||
from django_filters import Filter, MultipleChoiceFilter, VERSION
|
||||
from django_filters.filterset import BaseFilterSet, FilterSet
|
||||
from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS
|
||||
|
||||
from graphql_relay.node.node import from_global_id
|
||||
|
||||
from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField
|
||||
|
||||
|
||||
class GlobalIDFilter(Filter):
|
||||
field_class = GlobalIDFormField
|
||||
|
||||
def filter(self, qs, value):
|
||||
""" Convert the filter value to a primary key before filtering """
|
||||
_id = None
|
||||
if value is not None:
|
||||
_, _id = from_global_id(value)
|
||||
return super(GlobalIDFilter, self).filter(qs, _id)
|
||||
|
||||
|
||||
class GlobalIDMultipleChoiceFilter(MultipleChoiceFilter):
|
||||
field_class = GlobalIDMultipleChoiceField
|
||||
|
||||
def filter(self, qs, value):
|
||||
gids = [from_global_id(v)[1] for v in value]
|
||||
return super(GlobalIDMultipleChoiceFilter, self).filter(qs, gids)
|
||||
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},
|
||||
|
@ -40,8 +20,8 @@ GRAPHENE_FILTER_SET_OVERRIDES = {
|
|||
|
||||
|
||||
class GrapheneFilterSetMixin(BaseFilterSet):
|
||||
""" A django_filters.filterset.BaseFilterSet with default filter overrides
|
||||
to handle global IDs """
|
||||
"""A django_filters.filterset.BaseFilterSet with default filter overrides
|
||||
to handle global IDs"""
|
||||
|
||||
FILTER_DEFAULTS = dict(
|
||||
itertools.chain(
|
||||
|
@ -50,51 +30,19 @@ class GrapheneFilterSetMixin(BaseFilterSet):
|
|||
)
|
||||
|
||||
|
||||
# To support a Django 1.11 + Python 2.7 combination django-filter must be
|
||||
# < 2.x.x. To support the earlier version of django-filter, the
|
||||
# filter_for_reverse_field method must be present on GrapheneFilterSetMixin and
|
||||
# must not be present for later versions of django-filter.
|
||||
if VERSION[0] < 2:
|
||||
from django.utils.text import capfirst
|
||||
|
||||
class GrapheneFilterSetMixinPython2(GrapheneFilterSetMixin):
|
||||
@classmethod
|
||||
def filter_for_reverse_field(cls, f, name):
|
||||
"""Handles retrieving filters for reverse relationships
|
||||
We override the default implementation so that we can handle
|
||||
Global IDs (the default implementation expects database
|
||||
primary keys)
|
||||
"""
|
||||
try:
|
||||
rel = f.field.remote_field
|
||||
except AttributeError:
|
||||
rel = f.field.rel
|
||||
default = {"name": name, "label": capfirst(rel.related_name)}
|
||||
if rel.multiple:
|
||||
# For to-many relationships
|
||||
return GlobalIDMultipleChoiceFilter(**default)
|
||||
else:
|
||||
# For to-one relationships
|
||||
return GlobalIDFilter(**default)
|
||||
|
||||
GrapheneFilterSetMixin = GrapheneFilterSetMixinPython2
|
||||
|
||||
|
||||
def setup_filterset(filterset_class):
|
||||
""" Wrap a provided filterset in Graphene-specific functionality
|
||||
"""
|
||||
"""Wrap a provided filterset in Graphene-specific functionality"""
|
||||
return type(
|
||||
"Graphene{}".format(filterset_class.__name__),
|
||||
f"Graphene{filterset_class.__name__}",
|
||||
(filterset_class, GrapheneFilterSetMixin),
|
||||
{},
|
||||
)
|
||||
|
||||
|
||||
def custom_filterset_factory(model, filterset_base_class=FilterSet, **meta):
|
||||
""" Create a filterset for the given model using the provided meta data
|
||||
"""
|
||||
"""Create a filterset for the given model using the provided meta data"""
|
||||
meta.update({"model": model})
|
||||
meta_class = type(str("Meta"), (object,), meta)
|
||||
meta_class = type("Meta", (object,), meta)
|
||||
filterset = type(
|
||||
str("%sFilterSet" % model._meta.object_name),
|
||||
(filterset_base_class, GrapheneFilterSetMixin),
|
||||
|
|
192
graphene_django/filter/tests/conftest.py
Normal file
192
graphene_django/filter/tests/conftest.py
Normal file
|
@ -0,0 +1,192 @@
|
|||
from functools import reduce
|
||||
|
||||
import pytest
|
||||
from django.db import models
|
||||
from django.db.models.query import QuerySet
|
||||
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 ...compat import ArrayField
|
||||
|
||||
pytestmark = []
|
||||
|
||||
if DJANGO_FILTER_INSTALLED:
|
||||
from graphene_django.filter import DjangoFilterConnectionField
|
||||
else:
|
||||
pytestmark.append(
|
||||
pytest.mark.skipif(
|
||||
True, reason="django_filters not installed or not compatible"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class 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():
|
||||
class EventFilterSet(FilterSet):
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = {
|
||||
"name": ["exact", "contains"],
|
||||
}
|
||||
|
||||
# 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")
|
||||
tags_ids__overlap = ArrayFilter(field_name="tag_ids", lookup_expr="overlap")
|
||||
tags_ids = ArrayFilter(field_name="tag_ids", lookup_expr="exact")
|
||||
random_field__contains = ArrayFilter(
|
||||
field_name="random_field", lookup_expr="contains"
|
||||
)
|
||||
random_field__overlap = ArrayFilter(
|
||||
field_name="random_field", lookup_expr="overlap"
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def EventType(EventFilterSet):
|
||||
class EventType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Event
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filterset_class = EventFilterSet
|
||||
|
||||
return EventType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def Query(EventType):
|
||||
"""
|
||||
Note that we have to use a custom resolver to replicate the arrayfield filter behavior as
|
||||
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):
|
||||
class FakeQuerySet(QuerySet):
|
||||
def __init__(self, model=None):
|
||||
self.model = Event
|
||||
self.__store = list(events)
|
||||
|
||||
def all(self):
|
||||
return self
|
||||
|
||||
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:
|
||||
queryset.__store = list(
|
||||
filter(
|
||||
lambda e: not set(kwargs["tags__overlap"]).isdisjoint(
|
||||
set(e.tags)
|
||||
),
|
||||
queryset.__store,
|
||||
)
|
||||
)
|
||||
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 union(self, *args):
|
||||
queryset = FakeQuerySet()
|
||||
queryset.__store = self.__store
|
||||
for arg in args:
|
||||
queryset.__store += arg.__store
|
||||
return queryset
|
||||
|
||||
def none(self):
|
||||
queryset = FakeQuerySet()
|
||||
queryset.__store = []
|
||||
return queryset
|
||||
|
||||
def count(self):
|
||||
return len(self.__store)
|
||||
|
||||
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
|
||||
|
||||
def __getitem__(self, index):
|
||||
return self.__store[index]
|
||||
|
||||
return FakeQuerySet()
|
||||
|
||||
return Query
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def schema(Query):
|
||||
return graphene.Schema(query=Query)
|
|
@ -10,7 +10,7 @@ class ArticleFilter(django_filters.FilterSet):
|
|||
fields = {
|
||||
"headline": ["exact", "icontains"],
|
||||
"pub_date": ["gt", "lt", "exact"],
|
||||
"reporter": ["exact"],
|
||||
"reporter": ["exact", "in"],
|
||||
}
|
||||
|
||||
order_by = OrderingFilter(fields=("pub_date",))
|
||||
|
|
|
@ -0,0 +1,79 @@
|
|||
import pytest
|
||||
|
||||
from ...compat import ArrayField, MissingType
|
||||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_contains_multiple(schema):
|
||||
"""
|
||||
Test contains filter on a array field of string.
|
||||
"""
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Contains: ["concert", "music"]) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["events"]["edges"] == [
|
||||
{"node": {"name": "Live Show"}},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_contains_one(schema):
|
||||
"""
|
||||
Test contains filter on a array field of string.
|
||||
"""
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Contains: ["music"]) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["events"]["edges"] == [
|
||||
{"node": {"name": "Live Show"}},
|
||||
{"node": {"name": "Musical"}},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_contains_empty_list(schema):
|
||||
"""
|
||||
Test contains filter on a array field of string.
|
||||
"""
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Contains: []) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["events"]["edges"] == [
|
||||
{"node": {"name": "Live Show"}},
|
||||
{"node": {"name": "Musical"}},
|
||||
{"node": {"name": "Ballet"}},
|
||||
{"node": {"name": "Speech"}},
|
||||
]
|
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"
|
120
graphene_django/filter/tests/test_array_field_exact_filter.py
Normal file
120
graphene_django/filter/tests/test_array_field_exact_filter.py
Normal file
|
@ -0,0 +1,120 @@
|
|||
import pytest
|
||||
|
||||
from ...compat import ArrayField, MissingType
|
||||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_exact_no_match(schema):
|
||||
"""
|
||||
Test exact filter on a array field of string.
|
||||
"""
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags: ["concert", "music"]) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["events"]["edges"] == []
|
||||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_exact_match(schema):
|
||||
"""
|
||||
Test exact filter on a array field of string.
|
||||
"""
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags: ["movie", "music"]) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["events"]["edges"] == [
|
||||
{"node": {"name": "Musical"}},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_exact_empty_list(schema):
|
||||
"""
|
||||
Test exact filter on a array field of string.
|
||||
"""
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags: []) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["events"]["edges"] == [
|
||||
{"node": {"name": "Speech"}},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
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_str = str(schema)
|
||||
|
||||
assert (
|
||||
'''type EventType implements Node {
|
||||
"""The ID of the object"""
|
||||
id: ID!
|
||||
name: String!
|
||||
tags: [String!]!
|
||||
tagIds: [Int!]!
|
||||
randomField: [Boolean!]!
|
||||
}'''
|
||||
in schema_str
|
||||
)
|
||||
|
||||
filters = {
|
||||
"offset": "Int",
|
||||
"before": "String",
|
||||
"after": "String",
|
||||
"first": "Int",
|
||||
"last": "Int",
|
||||
"name": "String",
|
||||
"name_Contains": "String",
|
||||
"tags_Contains": "[String!]",
|
||||
"tags_Overlap": "[String!]",
|
||||
"tags": "[String!]",
|
||||
"tags_Len": "Int",
|
||||
"tags_Len_In": "[Int]",
|
||||
"tagsIds_Contains": "[Int!]",
|
||||
"tagsIds_Overlap": "[Int!]",
|
||||
"tagsIds": "[Int!]",
|
||||
"randomField_Contains": "[Boolean!]",
|
||||
"randomField_Overlap": "[Boolean!]",
|
||||
"randomField": "[Boolean!]",
|
||||
}
|
||||
filters_str = ", ".join(
|
||||
[f"{filter_field}: {gql_type}" for filter_field, gql_type in filters.items()]
|
||||
)
|
||||
assert (
|
||||
f"type Query {{\n events({filters_str}): EventTypeConnection\n}}" in schema_str
|
||||
)
|
|
@ -0,0 +1,76 @@
|
|||
import pytest
|
||||
|
||||
from ...compat import ArrayField, MissingType
|
||||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_overlap_multiple(schema):
|
||||
"""
|
||||
Test overlap filter on a array field of string.
|
||||
"""
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Overlap: ["concert", "music"]) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["events"]["edges"] == [
|
||||
{"node": {"name": "Live Show"}},
|
||||
{"node": {"name": "Musical"}},
|
||||
{"node": {"name": "Ballet"}},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_overlap_one(schema):
|
||||
"""
|
||||
Test overlap filter on a array field of string.
|
||||
"""
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Overlap: ["music"]) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["events"]["edges"] == [
|
||||
{"node": {"name": "Live Show"}},
|
||||
{"node": {"name": "Musical"}},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||
def test_array_field_overlap_empty_list(schema):
|
||||
"""
|
||||
Test overlap filter on a array field of string.
|
||||
"""
|
||||
|
||||
query = """
|
||||
query {
|
||||
events (tags_Overlap: []) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["events"]["edges"] == []
|
162
graphene_django/filter/tests/test_enum_filtering.py
Normal file
162
graphene_django/filter/tests/test_enum_filtering.py
Normal file
|
@ -0,0 +1,162 @@
|
|||
import pytest
|
||||
|
||||
import graphene
|
||||
from graphene.relay import Node
|
||||
from graphene_django import DjangoConnectionField, DjangoObjectType
|
||||
from graphene_django.tests.models import Article, Reporter
|
||||
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
||||
|
||||
pytestmark = []
|
||||
|
||||
if DJANGO_FILTER_INSTALLED:
|
||||
from graphene_django.filter import DjangoFilterConnectionField
|
||||
else:
|
||||
pytestmark.append(
|
||||
pytest.mark.skipif(
|
||||
True, reason="django_filters not installed or not compatible"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def schema():
|
||||
class ReporterType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
|
||||
class ArticleType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Article
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = {
|
||||
"lang": ["exact", "in"],
|
||||
"reporter__a_choice": ["exact", "in"],
|
||||
}
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
all_reporters = DjangoConnectionField(ReporterType)
|
||||
all_articles = DjangoFilterConnectionField(ArticleType)
|
||||
|
||||
schema = graphene.Schema(query=Query)
|
||||
return schema
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def reporter_article_data():
|
||||
john = Reporter.objects.create(
|
||||
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
|
||||
)
|
||||
jane = Reporter.objects.create(
|
||||
first_name="Jane", last_name="Doe", email="janedoe@example.com", a_choice=2
|
||||
)
|
||||
Article.objects.create(
|
||||
headline="Article Node 1", reporter=john, editor=john, lang="es"
|
||||
)
|
||||
Article.objects.create(
|
||||
headline="Article Node 2", reporter=john, editor=john, lang="en"
|
||||
)
|
||||
Article.objects.create(
|
||||
headline="Article Node 3", reporter=jane, editor=jane, lang="en"
|
||||
)
|
||||
|
||||
|
||||
def test_filter_enum_on_connection(schema, reporter_article_data):
|
||||
"""
|
||||
Check that we can filter with enums on a connection.
|
||||
"""
|
||||
query = """
|
||||
query {
|
||||
allArticles(lang: ES) {
|
||||
edges {
|
||||
node {
|
||||
headline
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
expected = {
|
||||
"allArticles": {
|
||||
"edges": [
|
||||
{"node": {"headline": "Article Node 1"}},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data == expected
|
||||
|
||||
|
||||
def test_filter_on_foreign_key_enum_field(schema, reporter_article_data):
|
||||
"""
|
||||
Check that we can filter with enums on a field from a foreign key.
|
||||
"""
|
||||
query = """
|
||||
query {
|
||||
allArticles(reporter_AChoice: A_1) {
|
||||
edges {
|
||||
node {
|
||||
headline
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
expected = {
|
||||
"allArticles": {
|
||||
"edges": [
|
||||
{"node": {"headline": "Article Node 1"}},
|
||||
{"node": {"headline": "Article Node 2"}},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data == expected
|
||||
|
||||
|
||||
def test_filter_enum_field_schema_type(schema):
|
||||
"""
|
||||
Check that the type in the filter is an enum like on the object type.
|
||||
"""
|
||||
schema_str = str(schema)
|
||||
|
||||
assert (
|
||||
'''type ArticleType implements Node {
|
||||
"""The ID of the object"""
|
||||
id: ID!
|
||||
headline: String!
|
||||
pubDate: Date!
|
||||
pubDateTime: DateTime!
|
||||
reporter: ReporterType!
|
||||
editor: ReporterType!
|
||||
|
||||
"""Language"""
|
||||
lang: TestsArticleLangChoices!
|
||||
importance: TestsArticleImportanceChoices
|
||||
}'''
|
||||
in schema_str
|
||||
)
|
||||
|
||||
filters = {
|
||||
"offset": "Int",
|
||||
"before": "String",
|
||||
"after": "String",
|
||||
"first": "Int",
|
||||
"last": "Int",
|
||||
"lang": "TestsArticleLangChoices",
|
||||
"lang_In": "[TestsArticleLangChoices]",
|
||||
"reporter_AChoice": "TestsReporterAChoiceChoices",
|
||||
"reporter_AChoice_In": "[TestsReporterAChoiceChoices]",
|
||||
}
|
||||
filters_str = ", ".join(
|
||||
[f"{filter_field}: {gql_type}" for filter_field, gql_type in filters.items()]
|
||||
)
|
||||
assert f" allArticles({filters_str}): ArticleTypeConnection\n" in schema_str
|
|
@ -5,22 +5,22 @@ import pytest
|
|||
from django.db.models import TextField, Value
|
||||
from django.db.models.functions import Concat
|
||||
|
||||
from graphene import Argument, Boolean, Field, Float, ObjectType, Schema, String
|
||||
from graphene import Argument, Boolean, Decimal, Field, ObjectType, Schema, String
|
||||
from graphene.relay import Node
|
||||
from graphene_django import DjangoObjectType
|
||||
from graphene_django.forms import GlobalIDFormField, GlobalIDMultipleChoiceField
|
||||
from graphene_django.tests.models import Article, Pet, Reporter
|
||||
from graphene_django.tests.models import Article, Person, Pet, Reporter
|
||||
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
||||
|
||||
pytestmark = []
|
||||
|
||||
if DJANGO_FILTER_INSTALLED:
|
||||
import django_filters
|
||||
from django_filters import FilterSet, NumberFilter
|
||||
from django_filters import FilterSet, NumberFilter, OrderingFilter
|
||||
|
||||
from graphene_django.filter import (
|
||||
GlobalIDFilter,
|
||||
DjangoFilterConnectionField,
|
||||
GlobalIDFilter,
|
||||
GlobalIDMultipleChoiceFilter,
|
||||
)
|
||||
from graphene_django.filter.tests.filters import (
|
||||
|
@ -41,17 +41,20 @@ if DJANGO_FILTER_INSTALLED:
|
|||
class Meta:
|
||||
model = Article
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = ("headline",)
|
||||
|
||||
class ReporterNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
|
||||
class PetNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Pet
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
def get_args(field):
|
||||
|
@ -64,7 +67,7 @@ def assert_arguments(field, *arguments):
|
|||
actual = [name for name in args if name not in ignore and not name.startswith("_")]
|
||||
assert set(arguments) == set(
|
||||
actual
|
||||
), "Expected arguments ({}) did not match actual ({})".format(arguments, actual)
|
||||
), f"Expected arguments ({arguments}) did not match actual ({actual})"
|
||||
|
||||
|
||||
def assert_orderable(field):
|
||||
|
@ -87,6 +90,7 @@ def test_filter_explicit_filterset_arguments():
|
|||
"pub_date__gt",
|
||||
"pub_date__lt",
|
||||
"reporter",
|
||||
"reporter__in",
|
||||
)
|
||||
|
||||
|
||||
|
@ -137,7 +141,7 @@ def test_filter_shortcut_filterset_context():
|
|||
|
||||
@property
|
||||
def qs(self):
|
||||
qs = super(ArticleContextFilter, self).qs
|
||||
qs = super().qs
|
||||
return qs.filter(reporter=self.request.reporter)
|
||||
|
||||
class Query(ObjectType):
|
||||
|
@ -162,7 +166,7 @@ def test_filter_shortcut_filterset_context():
|
|||
editor=r2,
|
||||
)
|
||||
|
||||
class context(object):
|
||||
class context:
|
||||
reporter = r2
|
||||
|
||||
query = """
|
||||
|
@ -189,6 +193,7 @@ def test_filter_filterset_information_on_meta():
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = ["first_name", "articles"]
|
||||
|
||||
field = DjangoFilterConnectionField(ReporterFilterNode)
|
||||
|
@ -201,12 +206,14 @@ def test_filter_filterset_information_on_meta_related():
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = ["first_name", "articles"]
|
||||
|
||||
class ArticleFilterNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Article
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = ["headline", "reporter"]
|
||||
|
||||
class Query(ObjectType):
|
||||
|
@ -215,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)
|
||||
|
@ -233,6 +240,7 @@ def test_filter_filterset_class_filter_fields_exception():
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filterset_class = ReporterFilter
|
||||
filter_fields = ["first_name", "articles"]
|
||||
|
||||
|
@ -247,6 +255,7 @@ def test_filter_filterset_class_information_on_meta():
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filterset_class = ReporterFilter
|
||||
|
||||
field = DjangoFilterConnectionField(ReporterFilterNode)
|
||||
|
@ -269,12 +278,14 @@ def test_filter_filterset_class_information_on_meta_related():
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filterset_class = ReporterFilter
|
||||
|
||||
class ArticleFilterNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Article
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filterset_class = ArticleFilter
|
||||
|
||||
class Query(ObjectType):
|
||||
|
@ -283,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)
|
||||
|
@ -294,12 +305,14 @@ def test_filter_filterset_related_results():
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = ["first_name", "articles"]
|
||||
|
||||
class ArticleFilterNode(DjangoObjectType):
|
||||
class Meta:
|
||||
interfaces = (Node,)
|
||||
model = Article
|
||||
fields = "__all__"
|
||||
filter_fields = ["headline", "reporter"]
|
||||
|
||||
class Query(ObjectType):
|
||||
|
@ -388,7 +401,7 @@ def test_filterset_descriptions():
|
|||
field = DjangoFilterConnectionField(ArticleNode, filterset_class=ArticleIdFilter)
|
||||
max_time = field.args["max_time"]
|
||||
assert isinstance(max_time, Argument)
|
||||
assert max_time.type == Float
|
||||
assert max_time.type == Decimal
|
||||
assert max_time.description == "The maximum time"
|
||||
|
||||
|
||||
|
@ -405,12 +418,14 @@ def test_global_id_field_relation_with_filter():
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = ["first_name", "articles"]
|
||||
|
||||
class ArticleFilterNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Article
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = ["headline", "reporter"]
|
||||
|
||||
class Query(ObjectType):
|
||||
|
@ -460,12 +475,14 @@ def test_global_id_field_relation_with_filter_not_valid_id():
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = ["first_name", "articles"]
|
||||
|
||||
class ArticleFilterNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Article
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = ["headline", "reporter"]
|
||||
|
||||
class Query(ObjectType):
|
||||
|
@ -559,6 +576,7 @@ def test_filter_filterset_related_results_with_filter():
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = {"first_name": ["icontains"]}
|
||||
|
||||
class Query(ObjectType):
|
||||
|
@ -604,6 +622,7 @@ def test_recursive_filter_connection():
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
|
||||
class Query(ObjectType):
|
||||
all_reporters = DjangoFilterConnectionField(ReporterFilterNode)
|
||||
|
@ -629,11 +648,13 @@ def test_should_query_filter_node_limit():
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
|
||||
class ArticleType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Article
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = ("lang",)
|
||||
|
||||
class Query(ObjectType):
|
||||
|
@ -676,7 +697,7 @@ def test_should_query_filter_node_limit():
|
|||
node {
|
||||
id
|
||||
firstName
|
||||
articles(lang: "es") {
|
||||
articles(lang: ES) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
|
@ -718,6 +739,7 @@ def test_order_by():
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
|
||||
class Query(ObjectType):
|
||||
all_reporters = DjangoFilterConnectionField(
|
||||
|
@ -767,7 +789,7 @@ def test_order_by():
|
|||
|
||||
query = """
|
||||
query NodeFilteringQuery {
|
||||
allReporters(orderBy: "-firtsnaMe") {
|
||||
allReporters(orderBy: "-firstname") {
|
||||
edges {
|
||||
node {
|
||||
firstName
|
||||
|
@ -780,11 +802,12 @@ 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
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = ()
|
||||
|
||||
class Query(ObjectType):
|
||||
|
@ -851,6 +874,7 @@ def test_annotation_is_preserved():
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = ()
|
||||
|
||||
class Query(ObjectType):
|
||||
|
@ -893,6 +917,7 @@ def test_annotation_with_only():
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = ()
|
||||
|
||||
class Query(ObjectType):
|
||||
|
@ -933,6 +958,7 @@ def test_node_get_queryset_is_called():
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = ()
|
||||
|
||||
@classmethod
|
||||
|
@ -981,40 +1007,56 @@ def test_integer_field_filter_type():
|
|||
|
||||
assert str(schema) == dedent(
|
||||
"""\
|
||||
schema {
|
||||
query: Query
|
||||
type Query {
|
||||
pets(offset: Int, before: String, after: String, first: Int, last: Int, age: Int): PetTypeConnection
|
||||
}
|
||||
|
||||
interface Node {
|
||||
id: ID!
|
||||
type PetTypeConnection {
|
||||
\"""Pagination data for this connection.\"""
|
||||
pageInfo: PageInfo!
|
||||
|
||||
\"""Contains the nodes in this connection.\"""
|
||||
edges: [PetTypeEdge]!
|
||||
}
|
||||
|
||||
\"""
|
||||
The Relay compliant `PageInfo` type, containing data necessary to paginate this connection.
|
||||
\"""
|
||||
type PageInfo {
|
||||
\"""When paginating forwards, are there more items?\"""
|
||||
hasNextPage: Boolean!
|
||||
|
||||
\"""When paginating backwards, are there more items?\"""
|
||||
hasPreviousPage: Boolean!
|
||||
|
||||
\"""When paginating backwards, the cursor to continue.\"""
|
||||
startCursor: String
|
||||
|
||||
\"""When paginating forwards, the cursor to continue.\"""
|
||||
endCursor: String
|
||||
}
|
||||
|
||||
\"""A Relay edge containing a `PetType` and its cursor.\"""
|
||||
type PetTypeEdge {
|
||||
\"""The item at the end of the edge\"""
|
||||
node: PetType
|
||||
|
||||
\"""A cursor for use in pagination\"""
|
||||
cursor: String!
|
||||
}
|
||||
|
||||
type PetType implements Node {
|
||||
age: Int!
|
||||
|
||||
\"""The ID of the object\"""
|
||||
id: ID!
|
||||
}
|
||||
|
||||
type PetTypeConnection {
|
||||
pageInfo: PageInfo!
|
||||
edges: [PetTypeEdge]!
|
||||
}
|
||||
|
||||
type PetTypeEdge {
|
||||
node: PetType
|
||||
cursor: String!
|
||||
}
|
||||
|
||||
type Query {
|
||||
pets(offset: Int, before: String, after: String, first: Int, last: Int, age: Int): PetTypeConnection
|
||||
}
|
||||
"""
|
||||
\"""An object with an ID\"""
|
||||
interface Node {
|
||||
\"""The ID of the object\"""
|
||||
id: ID!
|
||||
}"""
|
||||
)
|
||||
|
||||
|
||||
|
@ -1033,40 +1075,56 @@ def test_other_filter_types():
|
|||
|
||||
assert str(schema) == dedent(
|
||||
"""\
|
||||
schema {
|
||||
query: Query
|
||||
type Query {
|
||||
pets(offset: Int, before: String, after: String, first: Int, last: Int, age: Int, age_Isnull: Boolean, age_Lt: Int): PetTypeConnection
|
||||
}
|
||||
|
||||
interface Node {
|
||||
id: ID!
|
||||
type PetTypeConnection {
|
||||
\"""Pagination data for this connection.\"""
|
||||
pageInfo: PageInfo!
|
||||
|
||||
\"""Contains the nodes in this connection.\"""
|
||||
edges: [PetTypeEdge]!
|
||||
}
|
||||
|
||||
\"""
|
||||
The Relay compliant `PageInfo` type, containing data necessary to paginate this connection.
|
||||
\"""
|
||||
type PageInfo {
|
||||
\"""When paginating forwards, are there more items?\"""
|
||||
hasNextPage: Boolean!
|
||||
|
||||
\"""When paginating backwards, are there more items?\"""
|
||||
hasPreviousPage: Boolean!
|
||||
|
||||
\"""When paginating backwards, the cursor to continue.\"""
|
||||
startCursor: String
|
||||
|
||||
\"""When paginating forwards, the cursor to continue.\"""
|
||||
endCursor: String
|
||||
}
|
||||
|
||||
\"""A Relay edge containing a `PetType` and its cursor.\"""
|
||||
type PetTypeEdge {
|
||||
\"""The item at the end of the edge\"""
|
||||
node: PetType
|
||||
|
||||
\"""A cursor for use in pagination\"""
|
||||
cursor: String!
|
||||
}
|
||||
|
||||
type PetType implements Node {
|
||||
age: Int!
|
||||
|
||||
\"""The ID of the object\"""
|
||||
id: ID!
|
||||
}
|
||||
|
||||
type PetTypeConnection {
|
||||
pageInfo: PageInfo!
|
||||
edges: [PetTypeEdge]!
|
||||
}
|
||||
|
||||
type PetTypeEdge {
|
||||
node: PetType
|
||||
cursor: String!
|
||||
}
|
||||
|
||||
type Query {
|
||||
pets(offset: Int, before: String, after: String, first: Int, last: Int, age: Int, age_Isnull: Boolean, age_Lt: Int): PetTypeConnection
|
||||
}
|
||||
"""
|
||||
\"""An object with an ID\"""
|
||||
interface Node {
|
||||
\"""The ID of the object\"""
|
||||
id: ID!
|
||||
}"""
|
||||
)
|
||||
|
||||
|
||||
|
@ -1085,7 +1143,7 @@ def test_filter_filterset_based_on_mixin():
|
|||
|
||||
return filters
|
||||
|
||||
def filter_email_in(cls, queryset, name, value):
|
||||
def filter_email_in(self, queryset, name, value):
|
||||
return queryset.filter(**{name: [value]})
|
||||
|
||||
class NewArticleFilter(ArticleFilterMixin, ArticleFilter):
|
||||
|
@ -1095,6 +1153,7 @@ def test_filter_filterset_based_on_mixin():
|
|||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
|
||||
class NewArticleFilterNode(DjangoObjectType):
|
||||
viewer = Field(NewReporterNode)
|
||||
|
@ -1102,6 +1161,7 @@ def test_filter_filterset_based_on_mixin():
|
|||
class Meta:
|
||||
model = Article
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filterset_class = NewArticleFilter
|
||||
|
||||
def resolve_viewer(self, info):
|
||||
|
@ -1126,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,
|
||||
|
@ -1136,10 +1196,9 @@ def test_filter_filterset_based_on_mixin():
|
|||
|
||||
schema = Schema(query=Query)
|
||||
|
||||
query = (
|
||||
"""
|
||||
query NodeFilteringQuery {
|
||||
allArticles(viewer_Email_In: "%s") {
|
||||
query = """
|
||||
query NodeFilteringQuery ($email: String!) {
|
||||
allArticles(viewer_Email_In: $email) {
|
||||
edges {
|
||||
node {
|
||||
headline
|
||||
|
@ -1151,8 +1210,6 @@ def test_filter_filterset_based_on_mixin():
|
|||
}
|
||||
}
|
||||
"""
|
||||
% reporter_1.email
|
||||
)
|
||||
|
||||
expected = {
|
||||
"allArticles": {
|
||||
|
@ -1167,7 +1224,81 @@ def test_filter_filterset_based_on_mixin():
|
|||
}
|
||||
}
|
||||
|
||||
result = schema.execute(query)
|
||||
result = schema.execute(query, variable_values={"email": reporter_1.email})
|
||||
|
||||
assert not result.errors
|
||||
assert result.data == expected
|
||||
|
||||
|
||||
def test_filter_string_contains():
|
||||
class PersonType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Person
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = {"name": ["exact", "in", "contains", "icontains"]}
|
||||
|
||||
class Query(ObjectType):
|
||||
people = DjangoFilterConnectionField(PersonType)
|
||||
|
||||
schema = Schema(query=Query)
|
||||
|
||||
Person.objects.bulk_create(
|
||||
[
|
||||
Person(name="Jack"),
|
||||
Person(name="Joe"),
|
||||
Person(name="Jane"),
|
||||
Person(name="Peter"),
|
||||
Person(name="Bob"),
|
||||
]
|
||||
)
|
||||
query = """query nameContain($filter: String) {
|
||||
people(name_Contains: $filter) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}"""
|
||||
|
||||
result = schema.execute(query, variables={"filter": "Ja"})
|
||||
assert not result.errors
|
||||
assert result.data == {
|
||||
"people": {
|
||||
"edges": [
|
||||
{"node": {"name": "Jack"}},
|
||||
{"node": {"name": "Jane"}},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
result = schema.execute(query, variables={"filter": "o"})
|
||||
assert not result.errors
|
||||
assert result.data == {
|
||||
"people": {
|
||||
"edges": [
|
||||
{"node": {"name": "Joe"}},
|
||||
{"node": {"name": "Bob"}},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_only_custom_filters():
|
||||
class ReporterFilter(FilterSet):
|
||||
class Meta:
|
||||
model = Reporter
|
||||
fields = []
|
||||
|
||||
some_filter = OrderingFilter(fields=("name",))
|
||||
|
||||
class ReporterFilterNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filterset_class = ReporterFilter
|
||||
|
||||
field = DjangoFilterConnectionField(ReporterFilterNode)
|
||||
assert_arguments(field, "some_filter")
|
||||
|
|
|
@ -1,11 +1,16 @@
|
|||
import pytest
|
||||
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
|
||||
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 = []
|
||||
|
@ -20,40 +25,77 @@ else:
|
|||
)
|
||||
|
||||
|
||||
class PetNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Pet
|
||||
interfaces = (Node,)
|
||||
filter_fields = {
|
||||
"name": ["exact", "in"],
|
||||
"age": ["exact", "in", "range"],
|
||||
}
|
||||
@pytest.fixture
|
||||
def query():
|
||||
class PetNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Pet
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = {
|
||||
"id": ["exact", "in"],
|
||||
"name": ["exact", "in"],
|
||||
"age": ["exact", "in", "range"],
|
||||
}
|
||||
|
||||
class ReporterNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
# choice filter using enum
|
||||
filter_fields = {"reporter_type": ["exact", "in"]}
|
||||
|
||||
class ArticleNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Article
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filterset_class = ArticleFilter
|
||||
|
||||
class FilmNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Film
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
# choice filter not using enum
|
||||
filter_fields = {
|
||||
"genre": ["exact", "in"],
|
||||
}
|
||||
convert_choices_to_enum = False
|
||||
|
||||
class PersonFilterSet(FilterSet):
|
||||
class Meta:
|
||||
model = Person
|
||||
fields = {"name": ["in"]}
|
||||
|
||||
names = filters.BaseInFilter(method="filter_names")
|
||||
|
||||
def filter_names(self, qs, name, value):
|
||||
"""
|
||||
This custom filter take a string as input with comma separated values.
|
||||
Note that the value here is already a list as it has been transformed by the BaseInFilter class.
|
||||
"""
|
||||
return qs.filter(name__in=value)
|
||||
|
||||
class PersonNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Person
|
||||
interfaces = (Node,)
|
||||
filterset_class = PersonFilterSet
|
||||
fields = "__all__"
|
||||
|
||||
class Query(ObjectType):
|
||||
pets = DjangoFilterConnectionField(PetNode)
|
||||
people = DjangoFilterConnectionField(PersonNode)
|
||||
articles = DjangoFilterConnectionField(ArticleNode)
|
||||
films = DjangoFilterConnectionField(FilmNode)
|
||||
reporters = DjangoFilterConnectionField(ReporterNode)
|
||||
|
||||
return Query
|
||||
|
||||
|
||||
class PersonFilterSet(FilterSet):
|
||||
class Meta:
|
||||
model = Person
|
||||
fields = {}
|
||||
|
||||
names = filters.BaseInFilter(method="filter_names")
|
||||
|
||||
def filter_names(self, qs, name, value):
|
||||
return qs.filter(name__in=value)
|
||||
|
||||
|
||||
class PersonNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Person
|
||||
interfaces = (Node,)
|
||||
filterset_class = PersonFilterSet
|
||||
|
||||
|
||||
class Query(ObjectType):
|
||||
pets = DjangoFilterConnectionField(PetNode)
|
||||
people = DjangoFilterConnectionField(PersonNode)
|
||||
|
||||
|
||||
def test_string_in_filter():
|
||||
def test_string_in_filter(query):
|
||||
"""
|
||||
Test in filter on a string field.
|
||||
"""
|
||||
|
@ -61,7 +103,7 @@ def test_string_in_filter():
|
|||
Pet.objects.create(name="Mimi", age=3)
|
||||
Pet.objects.create(name="Jojo, the rabbit", age=3)
|
||||
|
||||
schema = Schema(query=Query)
|
||||
schema = Schema(query=query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
|
@ -82,17 +124,19 @@ def test_string_in_filter():
|
|||
]
|
||||
|
||||
|
||||
def test_string_in_filter_with_filterset_class():
|
||||
"""Test in filter on a string field with a custom filterset class."""
|
||||
def test_string_in_filter_with_otjer_filter(query):
|
||||
"""
|
||||
Test in filter on a string field which has also a custom filter doing a similar operation.
|
||||
"""
|
||||
Person.objects.create(name="John")
|
||||
Person.objects.create(name="Michael")
|
||||
Person.objects.create(name="Angela")
|
||||
|
||||
schema = Schema(query=Query)
|
||||
schema = Schema(query=query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
people (names: ["John", "Michael"]) {
|
||||
people (name_In: ["John", "Michael"]) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
|
@ -109,7 +153,36 @@ def test_string_in_filter_with_filterset_class():
|
|||
]
|
||||
|
||||
|
||||
def test_int_in_filter():
|
||||
def test_string_in_filter_with_declared_filter(query):
|
||||
"""
|
||||
Test in filter on a string field with a custom filterset class.
|
||||
"""
|
||||
Person.objects.create(name="John")
|
||||
Person.objects.create(name="Michael")
|
||||
Person.objects.create(name="Angela")
|
||||
|
||||
schema = Schema(query=query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
people (names: "John,Michael") {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["people"]["edges"] == [
|
||||
{"node": {"name": "John"}},
|
||||
{"node": {"name": "Michael"}},
|
||||
]
|
||||
|
||||
|
||||
def test_int_in_filter(query):
|
||||
"""
|
||||
Test in filter on an integer field.
|
||||
"""
|
||||
|
@ -117,7 +190,7 @@ def test_int_in_filter():
|
|||
Pet.objects.create(name="Mimi", age=3)
|
||||
Pet.objects.create(name="Jojo, the rabbit", age=3)
|
||||
|
||||
schema = Schema(query=Query)
|
||||
schema = Schema(query=query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
|
@ -157,20 +230,19 @@ def test_int_in_filter():
|
|||
]
|
||||
|
||||
|
||||
def test_int_range_filter():
|
||||
def test_in_filter_with_empty_list(query):
|
||||
"""
|
||||
Test in filter on an integer field.
|
||||
Check that using a in filter with an empty list provided as input returns no objects.
|
||||
"""
|
||||
Pet.objects.create(name="Brutus", age=12)
|
||||
Pet.objects.create(name="Mimi", age=8)
|
||||
Pet.objects.create(name="Jojo, the rabbit", age=3)
|
||||
Pet.objects.create(name="Picotin", age=5)
|
||||
|
||||
schema = Schema(query=Query)
|
||||
schema = Schema(query=query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
pets (age_Range: [4, 9]) {
|
||||
pets (name_In: []) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
|
@ -181,7 +253,195 @@ def test_int_range_filter():
|
|||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["pets"]["edges"] == [
|
||||
{"node": {"name": "Mimi"}},
|
||||
{"node": {"name": "Picotin"}},
|
||||
assert len(result.data["pets"]["edges"]) == 0
|
||||
|
||||
|
||||
def test_choice_in_filter_without_enum(query):
|
||||
"""
|
||||
Test in filter o an choice field not using an enum (Film.genre).
|
||||
"""
|
||||
|
||||
john_doe = Reporter.objects.create(
|
||||
first_name="John", last_name="Doe", email="john@doe.com"
|
||||
)
|
||||
jean_bon = Reporter.objects.create(
|
||||
first_name="Jean", last_name="Bon", email="jean@bon.com"
|
||||
)
|
||||
documentary_film = Film.objects.create(genre="do")
|
||||
documentary_film.reporters.add(john_doe)
|
||||
action_film = Film.objects.create(genre="ac")
|
||||
action_film.reporters.add(john_doe)
|
||||
other_film = Film.objects.create(genre="ot")
|
||||
other_film.reporters.add(john_doe)
|
||||
other_film.reporters.add(jean_bon)
|
||||
|
||||
schema = Schema(query=query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
films (genre_In: ["do", "ac"]) {
|
||||
edges {
|
||||
node {
|
||||
genre
|
||||
reporters {
|
||||
edges {
|
||||
node {
|
||||
lastName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["films"]["edges"] == [
|
||||
{
|
||||
"node": {
|
||||
"genre": "do",
|
||||
"reporters": {"edges": [{"node": {"lastName": "Doe"}}]},
|
||||
}
|
||||
},
|
||||
{
|
||||
"node": {
|
||||
"genre": "ac",
|
||||
"reporters": {"edges": [{"node": {"lastName": "Doe"}}]},
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_fk_id_in_filter(query):
|
||||
"""
|
||||
Test in filter on an foreign key relationship.
|
||||
"""
|
||||
john_doe = Reporter.objects.create(
|
||||
first_name="John", last_name="Doe", email="john@doe.com"
|
||||
)
|
||||
jean_bon = Reporter.objects.create(
|
||||
first_name="Jean", last_name="Bon", email="jean@bon.com"
|
||||
)
|
||||
sara_croche = Reporter.objects.create(
|
||||
first_name="Sara", last_name="Croche", email="sara@croche.com"
|
||||
)
|
||||
Article.objects.create(
|
||||
headline="A",
|
||||
pub_date=datetime.now(),
|
||||
pub_date_time=datetime.now(),
|
||||
reporter=john_doe,
|
||||
editor=john_doe,
|
||||
)
|
||||
Article.objects.create(
|
||||
headline="B",
|
||||
pub_date=datetime.now(),
|
||||
pub_date_time=datetime.now(),
|
||||
reporter=jean_bon,
|
||||
editor=jean_bon,
|
||||
)
|
||||
Article.objects.create(
|
||||
headline="C",
|
||||
pub_date=datetime.now(),
|
||||
pub_date_time=datetime.now(),
|
||||
reporter=sara_croche,
|
||||
editor=sara_croche,
|
||||
)
|
||||
|
||||
schema = Schema(query=query)
|
||||
|
||||
query = f"""
|
||||
query {{
|
||||
articles (reporter_In: [{john_doe.id}, {jean_bon.id}]) {{
|
||||
edges {{
|
||||
node {{
|
||||
headline
|
||||
reporter {{
|
||||
lastName
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
}}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["articles"]["edges"] == [
|
||||
{"node": {"headline": "A", "reporter": {"lastName": "Doe"}}},
|
||||
{"node": {"headline": "B", "reporter": {"lastName": "Bon"}}},
|
||||
]
|
||||
|
||||
|
||||
def test_enum_in_filter(query):
|
||||
"""
|
||||
Test in filter on a choice field using an enum (Reporter.reporter_type).
|
||||
"""
|
||||
|
||||
Reporter.objects.create(
|
||||
first_name="John", last_name="Doe", email="john@doe.com", reporter_type=1
|
||||
)
|
||||
Reporter.objects.create(
|
||||
first_name="Jean", last_name="Bon", email="jean@bon.com", reporter_type=2
|
||||
)
|
||||
Reporter.objects.create(
|
||||
first_name="Jane", last_name="Doe", email="jane@doe.com", reporter_type=2
|
||||
)
|
||||
Reporter.objects.create(
|
||||
first_name="Jack", last_name="Black", email="jack@black.com", reporter_type=None
|
||||
)
|
||||
|
||||
schema = Schema(query=query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
reporters (reporterType_In: [A_1]) {
|
||||
edges {
|
||||
node {
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["reporters"]["edges"] == [
|
||||
{"node": {"email": "john@doe.com"}},
|
||||
]
|
||||
|
||||
query = """
|
||||
query {
|
||||
reporters (reporterType_In: [A_2]) {
|
||||
edges {
|
||||
node {
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["reporters"]["edges"] == [
|
||||
{"node": {"email": "jean@bon.com"}},
|
||||
{"node": {"email": "jane@doe.com"}},
|
||||
]
|
||||
|
||||
query = """
|
||||
query {
|
||||
reporters (reporterType_In: [A_2, A_1]) {
|
||||
edges {
|
||||
node {
|
||||
email
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["reporters"]["edges"] == [
|
||||
{"node": {"email": "john@doe.com"}},
|
||||
{"node": {"email": "jean@bon.com"}},
|
||||
{"node": {"email": "jane@doe.com"}},
|
||||
]
|
||||
|
|
114
graphene_django/filter/tests/test_range_filter.py
Normal file
114
graphene_django/filter/tests/test_range_filter.py
Normal file
|
@ -0,0 +1,114 @@
|
|||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from graphene import ObjectType, Schema
|
||||
from graphene.relay import Node
|
||||
from graphene_django import DjangoObjectType
|
||||
from graphene_django.tests.models import Pet
|
||||
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
||||
|
||||
pytestmark = []
|
||||
|
||||
if DJANGO_FILTER_INSTALLED:
|
||||
from graphene_django.filter import DjangoFilterConnectionField
|
||||
else:
|
||||
pytestmark.append(
|
||||
pytest.mark.skipif(
|
||||
True, reason="django_filters not installed or not compatible"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class PetNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Pet
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filter_fields = {
|
||||
"name": ["exact", "in"],
|
||||
"age": ["exact", "in", "range"],
|
||||
}
|
||||
|
||||
|
||||
class Query(ObjectType):
|
||||
pets = DjangoFilterConnectionField(PetNode)
|
||||
|
||||
|
||||
def test_int_range_filter():
|
||||
"""
|
||||
Test range filter on an integer field.
|
||||
"""
|
||||
Pet.objects.create(name="Brutus", age=12)
|
||||
Pet.objects.create(name="Mimi", age=8)
|
||||
Pet.objects.create(name="Jojo, the rabbit", age=3)
|
||||
Pet.objects.create(name="Picotin", age=5)
|
||||
|
||||
schema = Schema(query=Query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
pets (age_Range: [4, 9]) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["pets"]["edges"] == [
|
||||
{"node": {"name": "Mimi"}},
|
||||
{"node": {"name": "Picotin"}},
|
||||
]
|
||||
|
||||
|
||||
def test_range_filter_with_invalid_input():
|
||||
"""
|
||||
Test range filter used with invalid inputs raise an error.
|
||||
"""
|
||||
Pet.objects.create(name="Brutus", age=12)
|
||||
Pet.objects.create(name="Mimi", age=8)
|
||||
Pet.objects.create(name="Jojo, the rabbit", age=3)
|
||||
Pet.objects.create(name="Picotin", age=5)
|
||||
|
||||
schema = Schema(query=Query)
|
||||
|
||||
query = """
|
||||
query ($rangeValue: [Int]) {
|
||||
pets (age_Range: $rangeValue) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
expected_error = json.dumps(
|
||||
{
|
||||
"age__range": [
|
||||
{
|
||||
"message": "Invalid range specified: it needs to contain 2 values.",
|
||||
"code": "invalid",
|
||||
}
|
||||
]
|
||||
}
|
||||
)
|
||||
|
||||
# Empty list
|
||||
result = schema.execute(query, variables={"rangeValue": []})
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0].message == expected_error
|
||||
|
||||
# Only one item in the list
|
||||
result = schema.execute(query, variables={"rangeValue": [1]})
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0].message == expected_error
|
||||
|
||||
# More than 2 items in the list
|
||||
result = schema.execute(query, variables={"rangeValue": [1, 2, 3]})
|
||||
assert len(result.errors) == 1
|
||||
assert result.errors[0].message == expected_error
|
231
graphene_django/filter/tests/test_typed_filter.py
Normal file
231
graphene_django/filter/tests/test_typed_filter.py
Normal file
|
@ -0,0 +1,231 @@
|
|||
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
|
||||
|
||||
pytestmark = []
|
||||
|
||||
if DJANGO_FILTER_INSTALLED:
|
||||
from graphene_django.filter import (
|
||||
DjangoFilterConnectionField,
|
||||
ListFilter,
|
||||
TypedFilter,
|
||||
)
|
||||
else:
|
||||
pytestmark.append(
|
||||
pytest.mark.skipif(
|
||||
True, reason="django_filters not installed or not compatible"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def schema():
|
||||
class ArticleFilterSet(FilterSet):
|
||||
class Meta:
|
||||
model = Article
|
||||
fields = {
|
||||
"lang": ["exact", "in"],
|
||||
}
|
||||
|
||||
lang__contains = TypedFilter(
|
||||
field_name="lang", lookup_expr="icontains", input_type=graphene.String
|
||||
)
|
||||
lang__in_str = ListFilter(
|
||||
field_name="lang",
|
||||
lookup_expr="in",
|
||||
input_type=graphene.List(graphene.String),
|
||||
)
|
||||
first_n = TypedFilter(input_type=graphene.Int, method="first_n_filter")
|
||||
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]
|
||||
|
||||
def only_first_filter(self, queryset, _name, value):
|
||||
if value:
|
||||
return queryset[:1]
|
||||
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
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
filterset_class = ArticleFilterSet
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
articles = DjangoFilterConnectionField(ArticleType)
|
||||
|
||||
schema = graphene.Schema(query=Query)
|
||||
return schema
|
||||
|
||||
|
||||
def test_typed_filter_schema(schema):
|
||||
"""
|
||||
Check that the type provided in the filter is reflected in the schema.
|
||||
"""
|
||||
|
||||
schema_str = str(schema)
|
||||
|
||||
filters = {
|
||||
"offset": "Int",
|
||||
"before": "String",
|
||||
"after": "String",
|
||||
"first": "Int",
|
||||
"last": "Int",
|
||||
"lang": "TestsArticleLangChoices",
|
||||
"lang_In": "[TestsArticleLangChoices]",
|
||||
"lang_Contains": "String",
|
||||
"lang_InStr": "[String]",
|
||||
"firstN": "Int",
|
||||
"onlyFirst": "Boolean",
|
||||
"headlineSearch": "[String]",
|
||||
}
|
||||
|
||||
all_articles_filters = (
|
||||
schema_str.split(" articles(")[1]
|
||||
.split("): ArticleTypeConnection\n")[0]
|
||||
.split(", ")
|
||||
)
|
||||
|
||||
for filter_field, gql_type in filters.items():
|
||||
assert f"{filter_field}: {gql_type}" in all_articles_filters
|
||||
|
||||
|
||||
def test_typed_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_Contains: "n") { edges { node { headline } } } }'
|
||||
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["articles"]["edges"] == [
|
||||
{"node": {"headline": "C"}},
|
||||
]
|
||||
|
||||
query = "query { articles (firstN: 2) { edges { node { headline } } } }"
|
||||
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["articles"]["edges"] == [
|
||||
{"node": {"headline": "A"}},
|
||||
{"node": {"headline": "AB"}},
|
||||
]
|
||||
|
||||
query = "query { articles (onlyFirst: true) { edges { node { headline } } } }"
|
||||
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
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,54 +1,104 @@
|
|||
import six
|
||||
|
||||
from graphene import List
|
||||
|
||||
from django import forms
|
||||
from django_filters.utils import get_model_field
|
||||
from django_filters.filters import Filter, BaseCSVFilter
|
||||
|
||||
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):
|
||||
"""
|
||||
Try to get a model field corresponding Graphql type from the DjangoObjectType.
|
||||
"""
|
||||
object_type = registry.get_type_for_model(model)
|
||||
if object_type:
|
||||
object_type_field = object_type._meta.fields.get(field_name)
|
||||
if object_type_field:
|
||||
field_type = object_type_field.type
|
||||
if isinstance(field_type, graphene.NonNull):
|
||||
field_type = field_type.of_type
|
||||
return field_type
|
||||
return None
|
||||
|
||||
|
||||
def get_filtering_args_from_filterset(filterset_class, type):
|
||||
""" Inspect a FilterSet and produce the arguments to pass to
|
||||
a Graphene Field. These arguments will be available to
|
||||
filter against in the GraphQL
|
||||
"""
|
||||
Inspect a FilterSet and produce the arguments to pass to a Graphene Field.
|
||||
These arguments will be available to filter against in the GraphQL API.
|
||||
"""
|
||||
from ..forms.converter import convert_form_field
|
||||
|
||||
args = {}
|
||||
model = filterset_class._meta.model
|
||||
for name, filter_field in six.iteritems(filterset_class.base_filters):
|
||||
form_field = None
|
||||
registry = type._meta.registry
|
||||
for name, filter_field in filterset_class.base_filters.items():
|
||||
filter_type = filter_field.lookup_expr
|
||||
required = filter_field.extra.get("required", False)
|
||||
field_type = None
|
||||
form_field = None
|
||||
|
||||
if name in filterset_class.declared_filters:
|
||||
# Get the filter field from the explicitly declared filter
|
||||
form_field = filter_field.field
|
||||
field = convert_form_field(form_field)
|
||||
if (
|
||||
isinstance(filter_field, TypedFilter)
|
||||
and filter_field.input_type is not None
|
||||
):
|
||||
# First check if the filter input type has been explicitly given
|
||||
field_type = filter_field.input_type
|
||||
else:
|
||||
# Get the filter field with no explicit type declaration
|
||||
model_field = get_model_field(model, filter_field.field_name)
|
||||
if filter_type != "isnull" and hasattr(model_field, "formfield"):
|
||||
form_field = model_field.formfield(
|
||||
required=filter_field.extra.get("required", False)
|
||||
)
|
||||
if name not in filterset_class.declared_filters or isinstance(
|
||||
filter_field, TypedFilter
|
||||
):
|
||||
# Get the filter field for filters that are no explicitly declared.
|
||||
if filter_type == "isnull":
|
||||
field_type = graphene.Boolean
|
||||
else:
|
||||
model_field = get_model_field(model, filter_field.field_name)
|
||||
|
||||
# Fallback to field defined on filter if we can't get it from the
|
||||
# model field
|
||||
if not form_field:
|
||||
form_field = filter_field.field
|
||||
# Get the form field either from:
|
||||
# 1. the formfield corresponding to the model field
|
||||
# 2. the field defined on filter
|
||||
if hasattr(model_field, "formfield"):
|
||||
form_field = model_field.formfield(required=required)
|
||||
if not form_field:
|
||||
form_field = filter_field.field
|
||||
|
||||
field = convert_form_field(form_field)
|
||||
# First try to get the matching field type from the GraphQL DjangoObjectType
|
||||
if model_field:
|
||||
if (
|
||||
isinstance(form_field, forms.ModelChoiceField)
|
||||
or isinstance(form_field, forms.ModelMultipleChoiceField)
|
||||
or isinstance(form_field, GlobalIDMultipleChoiceField)
|
||||
or isinstance(form_field, GlobalIDFormField)
|
||||
):
|
||||
# Foreign key have dynamic types and filtering on a foreign key actually means filtering on its ID.
|
||||
field_type = get_field_type(
|
||||
registry, model_field.related_model, "id"
|
||||
)
|
||||
else:
|
||||
field_type = get_field_type(
|
||||
registry, model_field.model, model_field.name
|
||||
)
|
||||
|
||||
if filter_type in ["in", "range"]:
|
||||
# Replace CSV filters (`in`, `range`) argument type to be a list of
|
||||
# the same type as the field. See comments in
|
||||
# `replace_csv_filters` method for more details.
|
||||
field = List(field.get_type())
|
||||
if not field_type:
|
||||
# Fallback on converting the form field either because:
|
||||
# - it's an explicitly declared filters
|
||||
# - we did not manage to get the type from the model type
|
||||
form_field = form_field or filter_field.field
|
||||
field_type = convert_form_field(form_field).get_type()
|
||||
|
||||
field_type = field.Argument()
|
||||
field_type.description = filter_field.label
|
||||
args[name] = field_type
|
||||
if isinstance(filter_field, ListFilter) or isinstance(
|
||||
filter_field, RangeFilter
|
||||
):
|
||||
# Replace InFilter/RangeFilter filters (`in`, `range`) argument type to be a list of
|
||||
# the same type as the field. See comments in `replace_csv_filters` method for more details.
|
||||
field_type = graphene.List(field_type)
|
||||
|
||||
args[name] = graphene.Argument(
|
||||
field_type,
|
||||
description=filter_field.label,
|
||||
required=required,
|
||||
)
|
||||
|
||||
return args
|
||||
|
||||
|
@ -70,23 +120,39 @@ def get_filterset_class(filterset_class, **meta):
|
|||
|
||||
def replace_csv_filters(filterset_class):
|
||||
"""
|
||||
Replace the "in" and "range" filters (that are not explicitly declared) to not be BaseCSVFilter (BaseInFilter, BaseRangeFilter) objects anymore
|
||||
but regular Filter objects that simply use the input value as filter argument on the queryset.
|
||||
Replace the "in" and "range" filters (that are not explicitly declared)
|
||||
to not be BaseCSVFilter (BaseInFilter, BaseRangeFilter) objects anymore
|
||||
but our custom InFilter/RangeFilter filter class that use the input
|
||||
value as filter argument on the queryset.
|
||||
|
||||
This is because those BaseCSVFilter are expecting a string as input with comma separated value but with GraphQl we
|
||||
can actually have a list as input and have a proper type verification of each value in the list.
|
||||
This is because those BaseCSVFilter are expecting a string as input with
|
||||
comma separated values.
|
||||
But with GraphQl we can actually have a list as input and have a proper
|
||||
type verification of each value in the list.
|
||||
|
||||
See issue https://github.com/graphql-python/graphene-django/issues/1068.
|
||||
"""
|
||||
for name, filter_field in six.iteritems(filterset_class.base_filters):
|
||||
for name, filter_field in list(filterset_class.base_filters.items()):
|
||||
# Do not touch any declared filters
|
||||
if name in filterset_class.declared_filters:
|
||||
continue
|
||||
|
||||
filter_type = filter_field.lookup_expr
|
||||
if filter_type in ["in", "range"]:
|
||||
assert isinstance(filter_field, BaseCSVFilter)
|
||||
filterset_class.base_filters[name] = Filter(
|
||||
if filter_type == "in":
|
||||
filterset_class.base_filters[name] = ListFilter(
|
||||
field_name=filter_field.field_name,
|
||||
lookup_expr=filter_field.lookup_expr,
|
||||
label=filter_field.label,
|
||||
method=filter_field.method,
|
||||
exclude=filter_field.exclude,
|
||||
**filter_field.extra
|
||||
**filter_field.extra,
|
||||
)
|
||||
elif filter_type == "range":
|
||||
filterset_class.base_filters[name] = RangeFilter(
|
||||
field_name=filter_field.field_name,
|
||||
lookup_expr=filter_field.lookup_expr,
|
||||
label=filter_field.label,
|
||||
method=filter_field.method,
|
||||
exclude=filter_field.exclude,
|
||||
**filter_field.extra,
|
||||
)
|
||||
|
|
|
@ -1,20 +1,34 @@
|
|||
from functools import singledispatch
|
||||
|
||||
from django import forms
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from graphene import ID, Boolean, Float, Int, List, String, UUID, Date, DateTime, Time
|
||||
from graphene import (
|
||||
ID,
|
||||
UUID,
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Decimal,
|
||||
Float,
|
||||
Int,
|
||||
List,
|
||||
String,
|
||||
Time,
|
||||
)
|
||||
|
||||
from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField
|
||||
from ..utils import import_single_dispatch
|
||||
|
||||
|
||||
singledispatch = import_single_dispatch()
|
||||
def get_form_field_description(field):
|
||||
return str(field.help_text) if field.help_text else None
|
||||
|
||||
|
||||
@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"
|
||||
)
|
||||
|
||||
|
||||
|
@ -27,60 +41,74 @@ def convert_form_field(field):
|
|||
@convert_form_field.register(forms.RegexField)
|
||||
@convert_form_field.register(forms.Field)
|
||||
def convert_form_field_to_string(field):
|
||||
return String(description=field.help_text, required=field.required)
|
||||
return String(
|
||||
description=get_form_field_description(field), required=field.required
|
||||
)
|
||||
|
||||
|
||||
@convert_form_field.register(forms.UUIDField)
|
||||
def convert_form_field_to_uuid(field):
|
||||
return UUID(description=field.help_text, required=field.required)
|
||||
return UUID(description=get_form_field_description(field), required=field.required)
|
||||
|
||||
|
||||
@convert_form_field.register(forms.IntegerField)
|
||||
@convert_form_field.register(forms.NumberInput)
|
||||
def convert_form_field_to_int(field):
|
||||
return Int(description=field.help_text, required=field.required)
|
||||
return Int(description=get_form_field_description(field), required=field.required)
|
||||
|
||||
|
||||
@convert_form_field.register(forms.BooleanField)
|
||||
def convert_form_field_to_boolean(field):
|
||||
return Boolean(description=field.help_text, required=field.required)
|
||||
return Boolean(
|
||||
description=get_form_field_description(field), required=field.required
|
||||
)
|
||||
|
||||
|
||||
@convert_form_field.register(forms.NullBooleanField)
|
||||
def convert_form_field_to_nullboolean(field):
|
||||
return Boolean(description=field.help_text)
|
||||
return Boolean(description=get_form_field_description(field))
|
||||
|
||||
|
||||
@convert_form_field.register(forms.FloatField)
|
||||
def convert_form_field_to_float(field):
|
||||
return Float(description=get_form_field_description(field), required=field.required)
|
||||
|
||||
|
||||
@convert_form_field.register(forms.DecimalField)
|
||||
@convert_form_field.register(forms.FloatField)
|
||||
def convert_form_field_to_float(field):
|
||||
return Float(description=field.help_text, required=field.required)
|
||||
|
||||
|
||||
@convert_form_field.register(forms.ModelMultipleChoiceField)
|
||||
@convert_form_field.register(GlobalIDMultipleChoiceField)
|
||||
def convert_form_field_to_list(field):
|
||||
return List(ID, required=field.required)
|
||||
def convert_form_field_to_decimal(field):
|
||||
return Decimal(
|
||||
description=get_form_field_description(field), required=field.required
|
||||
)
|
||||
|
||||
|
||||
@convert_form_field.register(forms.MultipleChoiceField)
|
||||
def convert_form_field_to_string_list(field):
|
||||
return List(String, required=field.required)
|
||||
return List(
|
||||
String, description=get_form_field_description(field), required=field.required
|
||||
)
|
||||
|
||||
|
||||
@convert_form_field.register(forms.ModelMultipleChoiceField)
|
||||
@convert_form_field.register(GlobalIDMultipleChoiceField)
|
||||
def convert_form_field_to_id_list(field):
|
||||
return List(ID, required=field.required)
|
||||
|
||||
|
||||
@convert_form_field.register(forms.DateField)
|
||||
def convert_form_field_to_date(field):
|
||||
return Date(description=field.help_text, required=field.required)
|
||||
return Date(description=get_form_field_description(field), required=field.required)
|
||||
|
||||
|
||||
@convert_form_field.register(forms.DateTimeField)
|
||||
def convert_form_field_to_datetime(field):
|
||||
return DateTime(description=field.help_text, required=field.required)
|
||||
return DateTime(
|
||||
description=get_form_field_description(field), required=field.required
|
||||
)
|
||||
|
||||
|
||||
@convert_form_field.register(forms.TimeField)
|
||||
def convert_form_field_to_time(field):
|
||||
return Time(description=field.help_text, required=field.required)
|
||||
return Time(description=get_form_field_description(field), required=field.required)
|
||||
|
||||
|
||||
@convert_form_field.register(forms.ModelChoiceField)
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -14,10 +14,6 @@ from graphene.types.utils import yank_fields_from_attrs
|
|||
from graphene_django.constants import MUTATION_ERRORS_FLAG
|
||||
from graphene_django.registry import get_global_registry
|
||||
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import connection
|
||||
|
||||
from ..types import ErrorType
|
||||
from .converter import convert_form_field
|
||||
|
||||
|
@ -27,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
|
||||
)
|
||||
|
||||
|
@ -86,7 +81,6 @@ class DjangoFormMutation(BaseDjangoFormMutation):
|
|||
def __init_subclass_with_meta__(
|
||||
cls, form_class=None, only_fields=(), exclude_fields=(), **options
|
||||
):
|
||||
|
||||
if not form_class:
|
||||
raise Exception("form_class is required for DjangoFormMutation")
|
||||
|
||||
|
@ -99,13 +93,16 @@ class DjangoFormMutation(BaseDjangoFormMutation):
|
|||
_meta.fields = yank_fields_from_attrs(output_fields, _as=Field)
|
||||
|
||||
input_fields = yank_fields_from_attrs(input_fields, _as=InputField)
|
||||
super(DjangoFormMutation, cls).__init_subclass_with_meta__(
|
||||
super().__init_subclass_with_meta__(
|
||||
_meta=_meta, input_fields=input_fields, **options
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def perform_mutate(cls, form, info):
|
||||
form.save()
|
||||
if hasattr(form, "save"):
|
||||
# `save` method won't exist on plain Django forms, but this mutation can
|
||||
# in theory be used with `ModelForm`s as well and we do want to save them.
|
||||
form.save()
|
||||
return cls(errors=[], **form.cleaned_data)
|
||||
|
||||
|
||||
|
@ -118,7 +115,7 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
|
|||
class Meta:
|
||||
abstract = True
|
||||
|
||||
errors = graphene.List(ErrorType)
|
||||
errors = graphene.List(graphene.NonNull(ErrorType), required=True)
|
||||
|
||||
@classmethod
|
||||
def __init_subclass_with_meta__(
|
||||
|
@ -128,9 +125,8 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
|
|||
return_field_name=None,
|
||||
only_fields=(),
|
||||
exclude_fields=(),
|
||||
**options
|
||||
**options,
|
||||
):
|
||||
|
||||
if not form_class:
|
||||
raise Exception("form_class is required for DjangoModelFormMutation")
|
||||
|
||||
|
@ -148,7 +144,7 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
|
|||
registry = get_global_registry()
|
||||
model_type = registry.get_type_for_model(model)
|
||||
if not model_type:
|
||||
raise Exception("No type registered for model: {}".format(model.__name__))
|
||||
raise Exception(f"No type registered for model: {model.__name__}")
|
||||
|
||||
if not return_field_name:
|
||||
model_name = model.__name__
|
||||
|
@ -164,7 +160,7 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
|
|||
_meta.fields = yank_fields_from_attrs(output_fields, _as=Field)
|
||||
|
||||
input_fields = yank_fields_from_attrs(input_fields, _as=InputField)
|
||||
super(DjangoModelFormMutation, cls).__init_subclass_with_meta__(
|
||||
super().__init_subclass_with_meta__(
|
||||
_meta=_meta, input_fields=input_fields, **options
|
||||
)
|
||||
|
||||
|
|
|
@ -1,30 +1,34 @@
|
|||
from django import forms
|
||||
from py.test import raises
|
||||
from django import VERSION as DJANGO_VERSION, forms
|
||||
from pytest import raises
|
||||
|
||||
import graphene
|
||||
from graphene import (
|
||||
String,
|
||||
Int,
|
||||
Boolean,
|
||||
Float,
|
||||
ID,
|
||||
UUID,
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
Decimal,
|
||||
Float,
|
||||
Int,
|
||||
List,
|
||||
NonNull,
|
||||
DateTime,
|
||||
Date,
|
||||
String,
|
||||
Time,
|
||||
)
|
||||
|
||||
from ..converter import convert_form_field
|
||||
|
||||
|
||||
def assert_conversion(django_field, graphene_field, *args):
|
||||
field = django_field(*args, help_text="Custom Help Text")
|
||||
def assert_conversion(django_field, graphene_field, *args, **kwargs):
|
||||
# Arrange
|
||||
help_text = kwargs.setdefault("help_text", "Custom Help Text")
|
||||
field = django_field(*args, **kwargs)
|
||||
# Act
|
||||
graphene_type = convert_form_field(field)
|
||||
# Assert
|
||||
assert isinstance(graphene_type, graphene_field)
|
||||
field = graphene_type.Field()
|
||||
assert field.description == "Custom Help Text"
|
||||
assert field.description == help_text
|
||||
return field
|
||||
|
||||
|
||||
|
@ -59,7 +63,12 @@ def test_should_slug_convert_string():
|
|||
|
||||
|
||||
def test_should_url_convert_string():
|
||||
assert_conversion(forms.URLField, String)
|
||||
kwargs = {}
|
||||
if DJANGO_VERSION >= (5, 0):
|
||||
# silence RemovedInDjango60Warning
|
||||
kwargs["assume_scheme"] = "https"
|
||||
|
||||
assert_conversion(forms.URLField, String, **kwargs)
|
||||
|
||||
|
||||
def test_should_choice_convert_string():
|
||||
|
@ -75,8 +84,7 @@ def test_should_regex_convert_string():
|
|||
|
||||
|
||||
def test_should_uuid_convert_string():
|
||||
if hasattr(forms, "UUIDField"):
|
||||
assert_conversion(forms.UUIDField, UUID)
|
||||
assert_conversion(forms.UUIDField, UUID)
|
||||
|
||||
|
||||
def test_should_integer_convert_int():
|
||||
|
@ -97,8 +105,8 @@ def test_should_float_convert_float():
|
|||
assert_conversion(forms.FloatField, Float)
|
||||
|
||||
|
||||
def test_should_decimal_convert_float():
|
||||
assert_conversion(forms.DecimalField, Float)
|
||||
def test_should_decimal_convert_decimal():
|
||||
assert_conversion(forms.DecimalField, Decimal)
|
||||
|
||||
|
||||
def test_should_multiple_choice_convert_list():
|
||||
|
|
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,7 +1,6 @@
|
|||
import pytest
|
||||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from py.test import raises
|
||||
from pytest import raises
|
||||
|
||||
from graphene import Field, ObjectType, Schema, String
|
||||
from graphene_django import DjangoObjectType
|
||||
|
@ -65,11 +64,11 @@ def test_mutation_error_camelcased(graphene_settings):
|
|||
class Meta:
|
||||
form_class = ExtraPetForm
|
||||
|
||||
result = PetMutation.mutate_and_get_payload(None, None)
|
||||
assert {f.field for f in result.errors} == {"name", "age", "test_field"}
|
||||
graphene_settings.CAMELCASE_ERRORS = True
|
||||
result = PetMutation.mutate_and_get_payload(None, None)
|
||||
assert {f.field for f in result.errors} == {"name", "age", "testField"}
|
||||
graphene_settings.CAMELCASE_ERRORS = False
|
||||
result = PetMutation.mutate_and_get_payload(None, None)
|
||||
assert {f.field for f in result.errors} == {"name", "age", "test_field"}
|
||||
|
||||
|
||||
class MockQuery(ObjectType):
|
||||
|
@ -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
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user