mirror of
https://github.com/graphql-python/graphene-django.git
synced 2025-04-16 15:12:06 +03:00
Compare commits
218 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 | ||
|
40e5252936 | ||
|
8c48516093 | ||
|
c049ab7470 | ||
|
2d0b9ddd42 | ||
|
e559a42374 | ||
|
dab6080fcf | ||
|
558288afce | ||
|
8f63199a63 | ||
|
a51c2bffd9 | ||
|
0e12343853 | ||
|
cc3bd05472 | ||
|
99512c53a1 | ||
|
7b35695067 | ||
|
454b74052e | ||
|
4c0c821b74 | ||
|
4b7119d691 | ||
|
eb7a0265d8 | ||
|
0888c748fd | ||
|
f554911397 | ||
|
8571bc465a | ||
|
2140be5e6a | ||
|
8408c51bf9 | ||
|
8928ec2dbe | ||
|
65f41c1a17 | ||
|
ee3d4f521f | ||
|
2e806384f6 | ||
|
86a66db1f6 | ||
|
19e3eddddb | ||
|
f5d94fda1f | ||
|
88eefb0e07 | ||
|
26960359a2 | ||
|
6ce208db95 | ||
|
ac1f9ac360 | ||
|
48ed516b5e | ||
|
b1b57d815a | ||
|
53023423b6 | ||
|
5b1451132d | ||
|
33c6a54414 | ||
|
bd553be10e | ||
|
67a0492c12 | ||
|
11dbde3bea | ||
|
55769e814f | ||
|
da9f41c295 | ||
|
62f95f2858 | ||
|
6cfcddac5a | ||
|
2308965658 | ||
|
97de26bf2e | ||
|
b552dcac24 | ||
|
63cfbbf59a | ||
|
e439bf3727 | ||
|
057b491176 | ||
|
6aa6aaaa8c | ||
|
1205e29bef | ||
|
d50955a173 | ||
|
965ebdee13 | ||
|
88f6ec458c | ||
|
08d0cce55a | ||
|
8ddad41bb7 | ||
|
1bec8e44b7 | ||
|
3026181b28 | ||
|
3c229b619e | ||
|
3c6733e121 | ||
|
48bfc395ee | ||
|
56f1db80cf | ||
|
c00203499b | ||
|
40e9c66db3 | ||
|
5ff40d2d14 |
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)
|
||||
|
|
13
.github/stale.yml
vendored
13
.github/stale.yml
vendored
|
@ -1,7 +1,7 @@
|
|||
# Number of days of inactivity before an issue becomes stale
|
||||
daysUntilStale: 120
|
||||
daysUntilStale: false
|
||||
# Number of days of inactivity before a stale issue is closed
|
||||
daysUntilClose: 30
|
||||
daysUntilClose: false
|
||||
# Issues with these labels will never be considered stale
|
||||
exemptLabels:
|
||||
- pinned
|
||||
|
@ -13,9 +13,10 @@ exemptLabels:
|
|||
# Label to use when marking an issue as stale
|
||||
staleLabel: wontfix
|
||||
# Comment to post when marking an issue as stale. Set to `false` to disable
|
||||
markComment: >
|
||||
This issue has been automatically marked as stale because it has not had
|
||||
recent activity. It will be closed if no further activity occurs. Thank you
|
||||
for your contributions.
|
||||
markComment: false
|
||||
# markComment: >
|
||||
# This issue has been automatically marked as stale because it has not had
|
||||
# recent activity. It will be closed if no further activity occurs. Thank you
|
||||
# for your contributions.
|
||||
# Comment to post when closing a stale issue. Set to `false` to disable
|
||||
closeComment: false
|
||||
|
|
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
|
||||
|
|
29
.github/workflows/tests.yml
vendored
29
.github/workflows/tests.yml
vendored
|
@ -1,6 +1,10 @@
|
|||
name: Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
workflow_call:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
@ -8,13 +12,25 @@ jobs:
|
|||
strategy:
|
||||
max-parallel: 4
|
||||
matrix:
|
||||
django: ["2.2", "3.0"]
|
||||
python-version: ["3.6", "3.7", "3.8"]
|
||||
|
||||
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
|
||||
|
@ -25,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
|
||||
```
|
||||
```
|
||||
|
|
|
@ -1,3 +1,6 @@
|
|||
include README.md LICENSE
|
||||
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
|
||||
|
|
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
|
||||
|
|
160
README.md
160
README.md
|
@ -1,15 +1,12 @@
|
|||
#  Graphene-Django
|
||||
|
||||
|
||||
A [Django](https://www.djangoproject.com/) integration for [Graphene](http://graphene-python.org/).
|
||||
|
||||
[![travis][travis-image]][travis-url]
|
||||
[![build][build-image]][build-url]
|
||||
[![pypi][pypi-image]][pypi-url]
|
||||
[![Anaconda-Server Badge][conda-image]][conda-url]
|
||||
[![coveralls][coveralls-image]][coveralls-url]
|
||||
|
||||
[travis-image]: https://travis-ci.org/graphql-python/graphene-django.svg?branch=master&style=flat
|
||||
[travis-url]: https://travis-ci.org/graphql-python/graphene-django
|
||||
[build-image]: https://github.com/graphql-python/graphene-django/workflows/Tests/badge.svg
|
||||
[build-url]: https://github.com/graphql-python/graphene-django/actions
|
||||
[pypi-image]: https://img.shields.io/pypi/v/graphene-django.svg?style=flat
|
||||
[pypi-url]: https://pypi.org/project/graphene-django/
|
||||
[coveralls-image]: https://coveralls.io/repos/github/graphql-python/graphene-django/badge.svg?branch=master
|
||||
|
@ -17,102 +14,137 @@ A [Django](https://www.djangoproject.com/) integration for [Graphene](http://gra
|
|||
[conda-image]: https://img.shields.io/conda/vn/conda-forge/graphene-django.svg
|
||||
[conda-url]: https://anaconda.org/conda-forge/graphene-django
|
||||
|
||||
[💬 Join the community on Slack](https://join.slack.com/t/graphenetools/shared_invite/enQtOTE2MDQ1NTg4MDM1LTA4Nzk0MGU0NGEwNzUxZGNjNDQ4ZjAwNDJjMjY0OGE1ZDgxZTg4YjM2ZTc4MjE2ZTAzZjE2ZThhZTQzZTkyMmM)
|
||||
Graphene-Django is an open-source library that provides seamless integration between Django, a high-level Python web framework, and Graphene, a library for building GraphQL APIs. The library allows developers to create GraphQL APIs in Django quickly and efficiently while maintaining a high level of performance.
|
||||
|
||||
## Documentation
|
||||
## Features
|
||||
|
||||
[Visit the documentation to get started!](https://docs.graphene-python.org/projects/django/en/latest/)
|
||||
* Seamless integration with Django models
|
||||
* Automatic generation of GraphQL schema
|
||||
* Integration with Django's authentication and permission system
|
||||
* Easy querying and filtering of data
|
||||
* Support for Django's pagination system
|
||||
* Compatible with Django's form and validation system
|
||||
* Extensive documentation and community support
|
||||
|
||||
## Quickstart
|
||||
## Installation
|
||||
|
||||
For installing graphene, just run this command in your shell
|
||||
To install Graphene-Django, run the following command:
|
||||
|
||||
```bash
|
||||
pip install "graphene-django>=3"
|
||||
```sh
|
||||
pip install graphene-django
|
||||
```
|
||||
|
||||
### Settings
|
||||
## Configuration
|
||||
|
||||
After installation, add 'graphene_django' to your Django project's `INSTALLED_APPS` list and define the GraphQL schema in your project's settings:
|
||||
|
||||
```python
|
||||
INSTALLED_APPS = (
|
||||
INSTALLED_APPS = [
|
||||
# ...
|
||||
'django.contrib.staticfiles', # Required for GraphiQL
|
||||
'graphene_django',
|
||||
)
|
||||
]
|
||||
|
||||
GRAPHENE = {
|
||||
'SCHEMA': 'app.schema.schema' # Where your Graphene schema lives
|
||||
'SCHEMA': 'myapp.schema.schema'
|
||||
}
|
||||
```
|
||||
|
||||
### Urls
|
||||
## Usage
|
||||
|
||||
We need to set up a `GraphQL` endpoint in our Django app, so we can serve the queries.
|
||||
To use Graphene-Django, create a `schema.py` file in your Django app directory and define your GraphQL types and queries:
|
||||
|
||||
```python
|
||||
from django.urls import path
|
||||
from graphene_django.views import GraphQLView
|
||||
|
||||
urlpatterns = [
|
||||
# ...
|
||||
path('graphql', GraphQLView.as_view(graphiql=True)),
|
||||
]
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
Here is a simple Django model:
|
||||
|
||||
```python
|
||||
from django.db import models
|
||||
|
||||
class UserModel(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
last_name = models.CharField(max_length=100)
|
||||
```
|
||||
|
||||
To create a GraphQL schema for it you simply have to write the following:
|
||||
|
||||
```python
|
||||
from graphene_django import DjangoObjectType
|
||||
import graphene
|
||||
from graphene_django import DjangoObjectType
|
||||
from .models import MyModel
|
||||
|
||||
class User(DjangoObjectType):
|
||||
class MyModelType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = UserModel
|
||||
model = MyModel
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
users = graphene.List(User)
|
||||
mymodels = graphene.List(MyModelType)
|
||||
|
||||
def resolve_users(self, info):
|
||||
return UserModel.objects.all()
|
||||
def resolve_mymodels(self, info, **kwargs):
|
||||
return MyModel.objects.all()
|
||||
|
||||
schema = graphene.Schema(query=Query)
|
||||
```
|
||||
|
||||
Then you can query the schema:
|
||||
Then, expose the GraphQL API in your Django project's `urls.py` file:
|
||||
|
||||
```python
|
||||
query = '''
|
||||
query {
|
||||
users {
|
||||
name,
|
||||
lastName
|
||||
}
|
||||
}
|
||||
'''
|
||||
result = schema.execute(query)
|
||||
from django.urls import path
|
||||
from graphene_django.views import GraphQLView
|
||||
from . import schema
|
||||
|
||||
urlpatterns = [
|
||||
# ...
|
||||
path('graphql/', GraphQLView.as_view(graphiql=True)), # Given that schema path is defined in GRAPHENE['SCHEMA'] in your settings.py
|
||||
]
|
||||
```
|
||||
|
||||
To learn more check out the following [examples](examples/):
|
||||
## Testing
|
||||
|
||||
* **Schema with Filtering**: [Cookbook example](examples/cookbook)
|
||||
* **Relay Schema**: [Starwars Relay example](examples/starwars)
|
||||
Graphene-Django provides support for testing GraphQL APIs using Django's test client. To create tests, create a `tests.py` file in your Django app directory and write your test cases:
|
||||
|
||||
```python
|
||||
from django.test import TestCase
|
||||
from graphene_django.utils.testing import GraphQLTestCase
|
||||
from . import schema
|
||||
|
||||
class MyModelAPITestCase(GraphQLTestCase):
|
||||
GRAPHENE_SCHEMA = schema.schema
|
||||
|
||||
def test_query_all_mymodels(self):
|
||||
response = self.query(
|
||||
'''
|
||||
query {
|
||||
mymodels {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
'''
|
||||
)
|
||||
|
||||
self.assertResponseNoErrors(response)
|
||||
self.assertEqual(len(response.data['mymodels']), MyModel.objects.count())
|
||||
```
|
||||
|
||||
## Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md)
|
||||
Contributions to Graphene-Django are always welcome! To get started, check the repository's [issue tracker](https://github.com/graphql-python/graphene-django/issues) and [contribution guidelines](https://github.com/graphql-python/graphene-django/blob/main/CONTRIBUTING.md).
|
||||
|
||||
## License
|
||||
|
||||
Graphene-Django is released under the [MIT License](https://github.com/graphql-python/graphene-django/blob/main/LICENSE).
|
||||
|
||||
## Resources
|
||||
|
||||
* [Official GitHub Repository](https://github.com/graphql-python/graphene-django)
|
||||
* [Graphene Documentation](http://docs.graphene-python.org/en/latest/)
|
||||
* [Django Documentation](https://docs.djangoproject.com/en/stable/)
|
||||
* [GraphQL Specification](https://spec.graphql.org/)
|
||||
* [GraphiQL](https://github.com/graphql/graphiql) - An in-browser IDE for exploring GraphQL APIs
|
||||
* [Graphene-Django Community](https://spectrum.chat/graphene) - Join the community to discuss questions and share ideas related to Graphene-Django
|
||||
|
||||
## Tutorials and Examples
|
||||
|
||||
* [Official Graphene-Django Tutorial](https://docs.graphene-python.org/projects/django/en/latest/tutorial-plain/)
|
||||
* [Building a GraphQL API with Django and Graphene-Django](https://www.howtographql.com/graphql-python/0-introduction/)
|
||||
* [Real-world example: Django, Graphene, and Relay](https://github.com/graphql-python/swapi-graphene)
|
||||
|
||||
## Related Projects
|
||||
|
||||
* [Graphene](https://github.com/graphql-python/graphene) - A library for building GraphQL APIs in Python
|
||||
* [Graphene-SQLAlchemy](https://github.com/graphql-python/graphene-sqlalchemy) - Integration between Graphene and SQLAlchemy, an Object Relational Mapper (ORM) for Python
|
||||
* [Graphene-File-Upload](https://github.com/lmcgartland/graphene-file-upload) - A package providing an Upload scalar for handling file uploads in Graphene
|
||||
* [Graphene-Subscriptions](https://github.com/graphql-python/graphene-subscriptions) - A package for adding real-time subscriptions to Graphene-based GraphQL APIs
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter any issues or have questions regarding Graphene-Django, feel free to [submit an issue](https://github.com/graphql-python/graphene-django/issues/new) on the official GitHub repository. You can also ask for help and share your experiences with the Graphene-Django community on [💬 Discord](https://discord.gg/Fftt273T79)
|
||||
|
||||
## Release Notes
|
||||
|
||||
|
|
122
README.rst
122
README.rst
|
@ -1,122 +0,0 @@
|
|||
Please read
|
||||
`UPGRADE-v2.0.md <https://github.com/graphql-python/graphene/blob/master/UPGRADE-v2.0.md>`__
|
||||
to learn how to upgrade to Graphene ``2.0``.
|
||||
|
||||
--------------
|
||||
|
||||
|Graphene Logo| Graphene-Django |Build Status| |PyPI version| |Coverage Status|
|
||||
===============================================================================
|
||||
|
||||
A `Django <https://www.djangoproject.com/>`__ integration for
|
||||
`Graphene <http://graphene-python.org/>`__.
|
||||
|
||||
|
||||
Documentation
|
||||
-------------
|
||||
|
||||
`Visit the documentation to get started! <https://docs.graphene-python.org/projects/django/en/latest/>`__
|
||||
|
||||
Quickstart
|
||||
----------
|
||||
|
||||
For installing graphene, just run this command in your shell
|
||||
|
||||
.. code:: bash
|
||||
|
||||
pip install "graphene-django>=3"
|
||||
|
||||
Settings
|
||||
~~~~~~~~
|
||||
|
||||
.. code:: python
|
||||
|
||||
INSTALLED_APPS = (
|
||||
# ...
|
||||
'graphene_django',
|
||||
)
|
||||
|
||||
GRAPHENE = {
|
||||
'SCHEMA': 'app.schema.schema' # Where your Graphene schema lives
|
||||
}
|
||||
|
||||
Urls
|
||||
~~~~
|
||||
|
||||
We need to set up a ``GraphQL`` endpoint in our Django app, so we can
|
||||
serve the queries.
|
||||
|
||||
.. code:: python
|
||||
|
||||
from django.conf.urls import url
|
||||
from graphene_django.views import GraphQLView
|
||||
|
||||
urlpatterns = [
|
||||
# ...
|
||||
url(r'^graphql$', GraphQLView.as_view(graphiql=True)),
|
||||
]
|
||||
|
||||
Examples
|
||||
--------
|
||||
|
||||
Here is a simple Django model:
|
||||
|
||||
.. code:: python
|
||||
|
||||
from django.db import models
|
||||
|
||||
class UserModel(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
last_name = models.CharField(max_length=100)
|
||||
|
||||
To create a GraphQL schema for it you simply have to write the
|
||||
following:
|
||||
|
||||
.. code:: python
|
||||
|
||||
from graphene_django import DjangoObjectType
|
||||
import graphene
|
||||
|
||||
class User(DjangoObjectType):
|
||||
class Meta:
|
||||
model = UserModel
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
users = graphene.List(User)
|
||||
|
||||
@graphene.resolve_only_args
|
||||
def resolve_users(self):
|
||||
return UserModel.objects.all()
|
||||
|
||||
schema = graphene.Schema(query=Query)
|
||||
|
||||
Then you can simply query the schema:
|
||||
|
||||
.. code:: python
|
||||
|
||||
query = '''
|
||||
query {
|
||||
users {
|
||||
name,
|
||||
lastName
|
||||
}
|
||||
}
|
||||
'''
|
||||
result = schema.execute(query)
|
||||
|
||||
To learn more check out the following `examples <examples/>`__:
|
||||
|
||||
- **Schema with Filtering**: `Cookbook example <examples/cookbook>`__
|
||||
- **Relay Schema**: `Starwars Relay example <examples/starwars>`__
|
||||
|
||||
Contributing
|
||||
------------
|
||||
|
||||
See `CONTRIBUTING.md <CONTRIBUTING.md>`__.
|
||||
|
||||
.. |Graphene Logo| image:: http://graphene-python.org/favicon.png
|
||||
.. |Build Status| image:: https://travis-ci.org/graphql-python/graphene-django.svg?branch=master
|
||||
:target: https://travis-ci.org/graphql-python/graphene-django
|
||||
.. |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
|
|
@ -1,35 +0,0 @@
|
|||
import sys
|
||||
import os
|
||||
|
||||
ROOT_PATH = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, ROOT_PATH + '/examples/')
|
||||
|
||||
SECRET_KEY = 1
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'graphene_django',
|
||||
'graphene_django.rest_framework',
|
||||
'graphene_django.tests',
|
||||
'starwars',
|
||||
]
|
||||
|
||||
DATABASES = {
|
||||
'default': {
|
||||
'ENGINE': 'django.db.backends.sqlite3',
|
||||
'NAME': 'django_test.sqlite',
|
||||
}
|
||||
}
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
},
|
||||
]
|
||||
|
||||
GRAPHENE = {
|
||||
'SCHEMA': 'graphene_django.tests.schema_view.schema'
|
||||
}
|
||||
|
||||
ROOT_URLCONF = 'graphene_django.tests.urls'
|
|
@ -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
|
||||
---------------------------
|
||||
|
||||
|
@ -119,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
|
||||
------------------------------
|
||||
|
@ -172,8 +212,8 @@ For Django 2.2 and above:
|
|||
.. code:: python
|
||||
|
||||
urlpatterns = [
|
||||
# some other urls
|
||||
path('graphql', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
|
||||
# some other urls
|
||||
path('graphql/', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
|
||||
]
|
||||
|
||||
.. _LoginRequiredMixin: https://docs.djangoproject.com/en/dev/topics/auth/default/#the-loginrequired-mixin
|
||||
|
|
|
@ -78,7 +78,7 @@ release = "1.0.dev"
|
|||
#
|
||||
# This is also used if you do content translation via gettext catalogs.
|
||||
# Usually you set "language" from the command line for these cases.
|
||||
language = None
|
||||
# language = None
|
||||
|
||||
# There are two options for replacing |today|: either, you set today to some
|
||||
# non-false value, then it is used:
|
||||
|
@ -445,4 +445,7 @@ epub_exclude_files = ["search.html"]
|
|||
|
||||
|
||||
# Example configuration for intersphinx: refer to the Python standard library.
|
||||
intersphinx_mapping = {"https://docs.python.org/": None}
|
||||
intersphinx_mapping = {
|
||||
# "https://docs.python.org/": None,
|
||||
"python": ("https://docs.python.org/", None),
|
||||
}
|
||||
|
|
|
@ -3,8 +3,8 @@ Django Debug Middleware
|
|||
|
||||
You can debug your GraphQL queries in a similar way to
|
||||
`django-debug-toolbar <https://django-debug-toolbar.readthedocs.org/>`__,
|
||||
but outputing in the results in GraphQL response as fields, instead of
|
||||
the graphical HTML interface.
|
||||
but outputting in the results in GraphQL response as fields, instead of
|
||||
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.
|
||||
|
||||
|
@ -43,7 +43,7 @@ And in your ``settings.py``:
|
|||
Querying
|
||||
--------
|
||||
|
||||
You can query it for outputing all the sql transactions that happened in
|
||||
You can query it for outputting all the sql transactions that happened in
|
||||
the GraphQL request, like:
|
||||
|
||||
.. code::
|
||||
|
@ -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`.
|
||||
|
|
|
@ -2,8 +2,8 @@ Filtering
|
|||
=========
|
||||
|
||||
Graphene integrates with
|
||||
`django-filter <https://django-filter.readthedocs.io/en/master/>`__ to provide filtering of results.
|
||||
See the `usage documentation <https://django-filter.readthedocs.io/en/master/guide/usage.html#the-filter>`__
|
||||
`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``.
|
||||
|
@ -15,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
|
||||
|
@ -26,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
|
||||
-----------------
|
||||
|
@ -34,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:
|
||||
|
@ -192,7 +192,7 @@ in unison with the ``filter_fields`` parameter:
|
|||
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``).
|
||||
|
@ -230,7 +230,7 @@ Extend the tuple of fields if you want to order by more than one field.
|
|||
|
||||
order_by = OrderingFilter(
|
||||
fields=(
|
||||
('created_at', 'created_at'),
|
||||
('name', 'created_at'),
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -258,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
|
||||
|
|
|
@ -28,9 +28,11 @@ For more advanced use, check out the Relay tutorial.
|
|||
fields
|
||||
extra-types
|
||||
mutations
|
||||
subscriptions
|
||||
filtering
|
||||
authorization
|
||||
debug
|
||||
introspection
|
||||
validation
|
||||
testing
|
||||
settings
|
||||
|
|
|
@ -25,8 +25,8 @@ Add ``graphene_django`` to the ``INSTALLED_APPS`` in the ``settings.py`` file of
|
|||
|
||||
INSTALLED_APPS = [
|
||||
...
|
||||
'django.contrib.staticfiles', # Required for GraphiQL
|
||||
'graphene_django'
|
||||
"django.contrib.staticfiles", # Required for GraphiQL
|
||||
"graphene_django"
|
||||
]
|
||||
|
||||
|
||||
|
@ -51,7 +51,7 @@ Finally, define the schema location for Graphene in the ``settings.py`` file of
|
|||
.. code:: python
|
||||
|
||||
GRAPHENE = {
|
||||
'SCHEMA': 'django_root.schema.schema'
|
||||
"SCHEMA": "django_root.schema.schema"
|
||||
}
|
||||
|
||||
Where ``path.schema.schema`` is the location of the ``Schema`` object in your Django project.
|
||||
|
@ -63,7 +63,7 @@ The most basic ``schema.py`` looks like this:
|
|||
import graphene
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
pass
|
||||
hello = graphene.String(default_value="Hi!")
|
||||
|
||||
schema = graphene.Schema(query=Query)
|
||||
|
||||
|
@ -73,7 +73,7 @@ To learn how to extend the schema object for your project, read the basic tutori
|
|||
CSRF exempt
|
||||
-----------
|
||||
|
||||
If have enabled `CSRF protection <https://docs.djangoproject.com/en/3.0/ref/csrf/>`_ in your Django app
|
||||
If you have enabled `CSRF protection <https://docs.djangoproject.com/en/3.0/ref/csrf/>`_ in your Django app
|
||||
you will find that it prevents your API clients from POSTing to the ``graphql`` endpoint. You can either
|
||||
update your API client to pass the CSRF token with each request (the Django docs have a guide on how to do that: https://docs.djangoproject.com/en/3.0/ref/csrf/#ajax) or you can exempt your Graphql endpoint from CSRF protection by wrapping the ``GraphQLView`` with the ``csrf_exempt``
|
||||
decorator:
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -37,7 +37,8 @@ Simple example
|
|||
# The class attributes define the response of the mutation
|
||||
question = graphene.Field(QuestionType)
|
||||
|
||||
def mutate(self, info, text, id):
|
||||
@classmethod
|
||||
def mutate(cls, root, info, text, id):
|
||||
question = Question.objects.get(pk=id)
|
||||
question.text = text
|
||||
question.save()
|
||||
|
@ -124,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
|
||||
---------------------
|
||||
|
@ -231,3 +281,121 @@ This argument is also sent back to the client with the mutation result
|
|||
(you do not have to do anything). For services that manage
|
||||
a pool of many GraphQL requests in bulk, the ``clientIDMutation``
|
||||
allows you to match up a specific mutation with the response.
|
||||
|
||||
|
||||
|
||||
Django Database Transactions
|
||||
----------------------------
|
||||
|
||||
Django gives you a few ways to control how database transactions are managed.
|
||||
|
||||
Tying transactions to HTTP requests
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
A common way to handle transactions in Django is to wrap each request in a transaction.
|
||||
Set ``ATOMIC_REQUESTS`` settings to ``True`` in the configuration of each database for
|
||||
which you want to enable this behavior.
|
||||
|
||||
It works like this. Before calling ``GraphQLView`` Django starts a transaction. If the
|
||||
response is produced without problems, Django commits the transaction. If the view, a
|
||||
``DjangoFormMutation`` or a ``DjangoModelFormMutation`` produces an exception, Django
|
||||
rolls back the transaction.
|
||||
|
||||
.. warning::
|
||||
|
||||
While the simplicity of this transaction model is appealing, it also makes it
|
||||
inefficient when traffic increases. Opening a transaction for every request has some
|
||||
overhead. The impact on performance depends on the query patterns of your application
|
||||
and on how well your database handles locking.
|
||||
|
||||
Check the next section for a better solution.
|
||||
|
||||
Tying transactions to mutations
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
A mutation can contain multiple fields, just like a query. There's one important
|
||||
distinction between queries and mutations, other than the name:
|
||||
|
||||
..
|
||||
|
||||
`While query fields are executed in parallel, mutation fields run in series, one
|
||||
after the other.`
|
||||
|
||||
This means that if we send two ``incrementCredits`` mutations in one request, the first
|
||||
is guaranteed to finish before the second begins, ensuring that we don't end up with a
|
||||
race condition with ourselves.
|
||||
|
||||
On the other hand, if the first ``incrementCredits`` runs successfully but the second
|
||||
one does not, the operation cannot be retried as it is. That's why is a good idea to
|
||||
run all mutation fields in a transaction, to guarantee all occur or nothing occurs.
|
||||
|
||||
To enable this behavior for all databases set the graphene ``ATOMIC_MUTATIONS`` settings
|
||||
to ``True`` in your settings file:
|
||||
|
||||
.. code:: python
|
||||
|
||||
GRAPHENE = {
|
||||
# ...
|
||||
"ATOMIC_MUTATIONS": True,
|
||||
}
|
||||
|
||||
On the contrary, if you want to enable this behavior for a specific database, set
|
||||
``ATOMIC_MUTATIONS`` to ``True`` in your database settings:
|
||||
|
||||
.. code:: python
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
# ...
|
||||
"ATOMIC_MUTATIONS": True,
|
||||
},
|
||||
# ...
|
||||
}
|
||||
|
||||
Now, given the following example mutation:
|
||||
|
||||
.. code::
|
||||
|
||||
mutation IncreaseCreditsTwice {
|
||||
|
||||
increaseCredits1: increaseCredits(input: { amount: 10 }) {
|
||||
balance
|
||||
errors {
|
||||
field
|
||||
messages
|
||||
}
|
||||
}
|
||||
|
||||
increaseCredits2: increaseCredits(input: { amount: -1 }) {
|
||||
balance
|
||||
errors {
|
||||
field
|
||||
messages
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
The server is going to return something like:
|
||||
|
||||
.. code:: json
|
||||
|
||||
{
|
||||
"data": {
|
||||
"increaseCredits1": {
|
||||
"balance": 10.0,
|
||||
"errors": []
|
||||
},
|
||||
"increaseCredits2": {
|
||||
"balance": null,
|
||||
"errors": [
|
||||
{
|
||||
"field": "amount",
|
||||
"message": "Amount should be a positive number"
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
But the balance will remain the same.
|
||||
|
|
195
docs/queries.rst
195
docs/queries.rst
|
@ -20,28 +20,26 @@ Full example
|
|||
# my_app/schema.py
|
||||
|
||||
import graphene
|
||||
from graphene_django import DjangoObjectType
|
||||
|
||||
from graphene_django.types import DjangoObjectType
|
||||
from .models import Question
|
||||
|
||||
|
||||
class QuestionType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = '__all__'
|
||||
fields = ("id", "question_text")
|
||||
|
||||
|
||||
class Query:
|
||||
class Query(graphene.ObjectType):
|
||||
questions = graphene.List(QuestionType)
|
||||
question = graphene.Field(QuestionType, question_id=graphene.String())
|
||||
question_by_id = graphene.Field(QuestionType, id=graphene.String())
|
||||
|
||||
def resolve_questions(self, info, **kwargs):
|
||||
def resolve_questions(root, info, **kwargs):
|
||||
# Querying a list
|
||||
return Question.objects.all()
|
||||
|
||||
def resolve_question(self, info, question_id):
|
||||
def resolve_question_by_id(root, info, id):
|
||||
# Querying a single question
|
||||
return Question.objects.get(pk=question_id)
|
||||
return Question.objects.get(pk=id)
|
||||
|
||||
|
||||
Specifying which fields to include
|
||||
|
@ -64,21 +62,27 @@ Show **only** these fields on the model:
|
|||
|
||||
.. code:: python
|
||||
|
||||
from graphene_django import DjangoObjectType
|
||||
from .models import Question
|
||||
|
||||
class QuestionType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = ('id', 'question_text')
|
||||
fields = ("id", "question_text")
|
||||
|
||||
You can also set the ``fields`` attribute to the special value ``'__all__'`` to indicate that all fields in the model should be used.
|
||||
You can also set the ``fields`` attribute to the special value ``"__all__"`` to indicate that all fields in the model should be used.
|
||||
|
||||
For example:
|
||||
|
||||
.. code:: python
|
||||
|
||||
from graphene_django import DjangoObjectType
|
||||
from .models import Question
|
||||
|
||||
class QuestionType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = '__all__'
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
``exclude``
|
||||
|
@ -88,10 +92,13 @@ Show all fields **except** those in ``exclude``:
|
|||
|
||||
.. code:: python
|
||||
|
||||
from graphene_django import DjangoObjectType
|
||||
from .models import Question
|
||||
|
||||
class QuestionType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Question
|
||||
exclude = ('question_text',)
|
||||
exclude = ("question_text",)
|
||||
|
||||
|
||||
Customising fields
|
||||
|
@ -101,16 +108,19 @@ You can completely overwrite a field, or add new fields, to a ``DjangoObjectType
|
|||
|
||||
.. code:: python
|
||||
|
||||
from graphene_django import DjangoObjectType
|
||||
from .models import Question
|
||||
|
||||
class QuestionType(DjangoObjectType):
|
||||
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = ('id', 'question_text')
|
||||
fields = ("id", "question_text")
|
||||
|
||||
extra_field = graphene.String()
|
||||
|
||||
def resolve_extra_field(self, info):
|
||||
return 'hello!'
|
||||
return "hello!"
|
||||
|
||||
|
||||
Choices to Enum conversion
|
||||
|
@ -125,17 +135,23 @@ For example the following ``Model`` and ``DjangoObjectType``:
|
|||
|
||||
.. code:: python
|
||||
|
||||
class PetModel(models.Model):
|
||||
kind = models.CharField(max_length=100, choices=(('cat', 'Cat'), ('dog', 'Dog')))
|
||||
from django.db import models
|
||||
from graphene_django import DjangoObjectType
|
||||
|
||||
class Pet(DjangoObjectType):
|
||||
class Meta:
|
||||
model = PetModel
|
||||
fields = '__all__'
|
||||
class PetModel(models.Model):
|
||||
kind = models.CharField(
|
||||
max_length=100,
|
||||
choices=(("cat", "Cat"), ("dog", "Dog"))
|
||||
)
|
||||
|
||||
class Pet(DjangoObjectType):
|
||||
class Meta:
|
||||
model = PetModel
|
||||
fields = ("id", "kind",)
|
||||
|
||||
Results in the following GraphQL schema definition:
|
||||
|
||||
.. code::
|
||||
.. code:: graphql
|
||||
|
||||
type Pet {
|
||||
id: ID!
|
||||
|
@ -153,29 +169,35 @@ You can disable this automatic conversion by setting
|
|||
|
||||
.. code:: python
|
||||
|
||||
class Pet(DjangoObjectType):
|
||||
class Meta:
|
||||
model = PetModel
|
||||
fields = '__all__'
|
||||
convert_choices_to_enum = False
|
||||
from graphene_django import DjangoObjectType
|
||||
from .models import PetModel
|
||||
|
||||
.. code::
|
||||
class Pet(DjangoObjectType):
|
||||
class Meta:
|
||||
model = PetModel
|
||||
fields = ("id", "kind",)
|
||||
convert_choices_to_enum = False
|
||||
|
||||
type Pet {
|
||||
id: ID!
|
||||
kind: String!
|
||||
}
|
||||
.. code:: graphql
|
||||
|
||||
type Pet {
|
||||
id: ID!
|
||||
kind: String!
|
||||
}
|
||||
|
||||
You can also set ``convert_choices_to_enum`` to a list of fields that should be
|
||||
automatically converted into enums:
|
||||
|
||||
.. code:: python
|
||||
|
||||
class Pet(DjangoObjectType):
|
||||
class Meta:
|
||||
model = PetModel
|
||||
fields = '__all__'
|
||||
convert_choices_to_enum = ['kind']
|
||||
from graphene_django import DjangoObjectType
|
||||
from .models import PetModel
|
||||
|
||||
class Pet(DjangoObjectType):
|
||||
class Meta:
|
||||
model = PetModel
|
||||
fields = ("id", "kind",)
|
||||
convert_choices_to_enum = ["kind"]
|
||||
|
||||
**Note:** Setting ``convert_choices_to_enum = []`` is the same as setting it to
|
||||
``False``.
|
||||
|
@ -188,6 +210,8 @@ Say you have the following models:
|
|||
|
||||
.. code:: python
|
||||
|
||||
from django.db import models
|
||||
|
||||
class Category(models.Model):
|
||||
foo = models.CharField(max_length=256)
|
||||
|
||||
|
@ -199,10 +223,13 @@ When ``Question`` is published as a ``DjangoObjectType`` and you want to add ``C
|
|||
|
||||
.. code:: python
|
||||
|
||||
from graphene_django import DjangoObjectType
|
||||
from .models import Question
|
||||
|
||||
class QuestionType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = ('category',)
|
||||
fields = ("category",)
|
||||
|
||||
Then all query-able related models must be defined as DjangoObjectType subclass,
|
||||
or they will fail to show if you are trying to query those relation fields. You only
|
||||
|
@ -210,10 +237,13 @@ need to create the most basic class for this to work:
|
|||
|
||||
.. code:: python
|
||||
|
||||
from graphene_django import DjangoObjectType
|
||||
from .models import Category
|
||||
|
||||
class CategoryType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Category
|
||||
fields = '__all__'
|
||||
fields = ("foo",)
|
||||
|
||||
.. _django-objecttype-get-queryset:
|
||||
|
||||
|
@ -228,11 +258,10 @@ Use this to control filtering on the ObjectType level instead of the Query objec
|
|||
from graphene_django.types import DjangoObjectType
|
||||
from .models import Question
|
||||
|
||||
|
||||
class QuestionType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = '__all__'
|
||||
fields = "__all__"
|
||||
|
||||
@classmethod
|
||||
def get_queryset(cls, queryset, info):
|
||||
|
@ -249,18 +278,22 @@ This resolve method should follow this format:
|
|||
|
||||
.. code:: python
|
||||
|
||||
def resolve_foo(self, info, **kwargs):
|
||||
def resolve_foo(parent, info, **kwargs):
|
||||
|
||||
Where "foo" is the name of the field declared in the ``Query`` object.
|
||||
|
||||
.. code:: python
|
||||
|
||||
class Query:
|
||||
import graphene
|
||||
from .models import Question
|
||||
from .types import QuestionType
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
foo = graphene.List(QuestionType)
|
||||
|
||||
def resolve_foo(self, info, **kwargs):
|
||||
id = kwargs.get('id')
|
||||
return QuestionModel.objects.get(id)
|
||||
def resolve_foo(root, info, **kwargs):
|
||||
id = kwargs.get("id")
|
||||
return Question.objects.get(id)
|
||||
|
||||
Arguments
|
||||
~~~~~~~~~
|
||||
|
@ -269,10 +302,18 @@ Additionally, Resolvers will receive **any arguments declared in the field defin
|
|||
|
||||
.. code:: python
|
||||
|
||||
class Query:
|
||||
question = graphene.Field(Question, foo=graphene.String(), bar=graphene.Int())
|
||||
import graphene
|
||||
from .models import Question
|
||||
from .types import QuestionType
|
||||
|
||||
def resolve_question(self, info, foo, bar):
|
||||
class Query(graphene.ObjectType):
|
||||
question = graphene.Field(
|
||||
QuestionType,
|
||||
foo=graphene.String(),
|
||||
bar=graphene.Int()
|
||||
)
|
||||
|
||||
def resolve_question(root, info, foo=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()
|
||||
|
||||
|
@ -287,12 +328,20 @@ of Django's ``HTTPRequest`` in your resolve methods, such as checking for authen
|
|||
|
||||
.. code:: python
|
||||
|
||||
def resolve_questions(self, info, **kwargs):
|
||||
# See if a user is authenticated
|
||||
if info.context.user.is_authenticated():
|
||||
return Question.objects.all()
|
||||
else:
|
||||
return Question.objects.none()
|
||||
import graphene
|
||||
|
||||
from .models import Question
|
||||
from .types import QuestionType
|
||||
|
||||
class Query(graphene.ObjectType):
|
||||
questions = graphene.List(QuestionType)
|
||||
|
||||
def resolve_questions(root, info):
|
||||
# See if a user is authenticated
|
||||
if info.context.user.is_authenticated():
|
||||
return Question.objects.all()
|
||||
else:
|
||||
return Question.objects.none()
|
||||
|
||||
|
||||
DjangoObjectTypes
|
||||
|
@ -314,15 +363,13 @@ Django models and your external API.
|
|||
import graphene
|
||||
from .models import Question
|
||||
|
||||
|
||||
class MyQuestion(graphene.ObjectType):
|
||||
text = graphene.String()
|
||||
|
||||
|
||||
class Query:
|
||||
class Query(graphene.ObjectType):
|
||||
question = graphene.Field(MyQuestion, question_id=graphene.String())
|
||||
|
||||
def resolve_question(self, info, question_id):
|
||||
def resolve_question(root, info, question_id):
|
||||
question = Question.objects.get(pk=question_id)
|
||||
return MyQuestion(
|
||||
text=question.question_text
|
||||
|
@ -352,52 +399,48 @@ the core graphene pages for more information on customizing the Relay experience
|
|||
from graphene_django import DjangoObjectType
|
||||
from .models import Question
|
||||
|
||||
|
||||
class QuestionType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Question
|
||||
fields = '__all__'
|
||||
interfaces = (relay.Node,)
|
||||
|
||||
interfaces = (relay.Node,) # make sure you add this
|
||||
fields = "__all__"
|
||||
|
||||
class QuestionConnection(relay.Connection):
|
||||
class Meta:
|
||||
node = QuestionType
|
||||
|
||||
|
||||
class Query:
|
||||
questions = relay.ConnectionField(QuestionConnection)
|
||||
|
||||
def resolve_questions(root, info, **kwargs):
|
||||
return Question.objects.all()
|
||||
|
||||
|
||||
You can now execute queries like:
|
||||
|
||||
|
||||
.. code:: python
|
||||
.. 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,60 +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
|
||||
fields = '__all__'
|
||||
class CategoryType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Category
|
||||
fields = "__all__"
|
||||
|
||||
|
||||
class IngredientType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Ingredient
|
||||
fields = '__all__'
|
||||
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
|
||||
|
||||
|
@ -104,7 +104,7 @@ Default: ``100``
|
|||
|
||||
|
||||
``CAMELCASE_ERRORS``
|
||||
------------------------------------
|
||||
--------------------
|
||||
|
||||
When set to ``True`` field names in the ``errors`` object will be camel case.
|
||||
By default they will be snake case.
|
||||
|
@ -142,6 +142,15 @@ Default: ``False``
|
|||
# ]
|
||||
|
||||
|
||||
``DJANGO_CHOICE_FIELD_ENUM_CONVERT``
|
||||
--------------------------------------
|
||||
|
||||
When set to ``True`` Django choice fields are automatically converted into Enum types.
|
||||
|
||||
Can be disabled globally by setting it to ``False``.
|
||||
|
||||
Default: ``True``
|
||||
|
||||
``DJANGO_CHOICE_FIELD_ENUM_V2_NAMING``
|
||||
--------------------------------------
|
||||
|
||||
|
@ -170,3 +179,113 @@ Default: ``None``
|
|||
GRAPHENE = {
|
||||
'DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME': "myapp.utils.enum_naming"
|
||||
}
|
||||
|
||||
|
||||
``SUBSCRIPTION_PATH``
|
||||
---------------------
|
||||
|
||||
Define an alternative URL path where subscription operations should be routed.
|
||||
|
||||
The GraphiQL interface will use this setting to intelligently route subscription operations. This is useful if you have more advanced infrastructure requirements that prevent websockets from being handled at the same path (e.g., a WSGI server listening at ``/graphql`` and an ASGI server listening at ``/ws/graphql``).
|
||||
|
||||
Default: ``None``
|
||||
|
||||
.. code:: python
|
||||
|
||||
GRAPHENE = {
|
||||
'SUBSCRIPTION_PATH': "/ws/graphql"
|
||||
}
|
||||
|
||||
|
||||
``GRAPHIQL_HEADER_EDITOR_ENABLED``
|
||||
----------------------------------
|
||||
|
||||
GraphiQL starting from version 1.0.0 allows setting custom headers in similar fashion to query variables.
|
||||
|
||||
Set to ``False`` if you want to disable GraphiQL headers editor tab for some reason.
|
||||
|
||||
This setting is passed to ``headerEditorEnabled`` GraphiQL options, for details refer to GraphiQLDocs_.
|
||||
|
||||
Default: ``True``
|
||||
|
||||
.. code:: python
|
||||
|
||||
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``
|
||||
|
|
42
docs/subscriptions.rst
Normal file
42
docs/subscriptions.rst
Normal file
|
@ -0,0 +1,42 @@
|
|||
Subscriptions
|
||||
=============
|
||||
|
||||
The ``graphene-django`` project does not currently support GraphQL subscriptions out of the box. However, there are
|
||||
several community-driven modules for adding subscription support, and the provided GraphiQL interface supports
|
||||
running subscription operations over a websocket.
|
||||
|
||||
To implement websocket-based support for GraphQL subscriptions, you’ll need to do the following:
|
||||
|
||||
1. Install and configure `django-channels <https://channels.readthedocs.io/en/latest/installation.html>`_.
|
||||
2. Install and configure* a third-party module for adding subscription support over websockets. A few options include:
|
||||
|
||||
- `graphql-python/graphql-ws <https://github.com/graphql-python/graphql-ws>`_
|
||||
- `datavance/django-channels-graphql-ws <https://github.com/datadvance/DjangoChannelsGraphqlWs>`_
|
||||
- `jaydenwindle/graphene-subscriptions <https://github.com/jaydenwindle/graphene-subscriptions>`_
|
||||
|
||||
3. Ensure that your application (or at least your GraphQL endpoint) is being served via an ASGI protocol server like
|
||||
daphne (built in to ``django-channels``), `uvicorn <https://www.uvicorn.org/>`_, or
|
||||
`hypercorn <https://pgjones.gitlab.io/hypercorn/>`_.
|
||||
|
||||
..
|
||||
|
||||
*** Note:** By default, the GraphiQL interface that comes with
|
||||
``graphene-django`` assumes that you are handling subscriptions at
|
||||
the same path as any other operation (i.e., you configured both
|
||||
``urls.py`` and ``routing.py`` to handle GraphQL operations at the
|
||||
same path, like ``/graphql``).
|
||||
|
||||
If these URLs differ, GraphiQL will try to run your subscription over
|
||||
HTTP, which will produce an error. If you need to use a different URL
|
||||
for handling websocket connections, you can configure
|
||||
``SUBSCRIPTION_PATH`` in your ``settings.py``:
|
||||
|
||||
.. code:: python
|
||||
|
||||
GRAPHENE = {
|
||||
# ...
|
||||
"SUBSCRIPTION_PATH": "/ws/graphql" # The path you configured in `routing.py`, including a leading slash.
|
||||
}
|
||||
|
||||
Once your application is properly configured to handle subscriptions, you can use the GraphiQL interface to test
|
||||
subscriptions like any other operation.
|
|
@ -1,9 +1,13 @@
|
|||
Testing API calls with django
|
||||
=============================
|
||||
|
||||
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:
|
||||
|
||||
|
@ -12,12 +16,8 @@ Usage:
|
|||
import json
|
||||
|
||||
from graphene_django.utils.testing import GraphQLTestCase
|
||||
from my_project.config.schema import schema
|
||||
|
||||
class MyFancyTestCase(GraphQLTestCase):
|
||||
# Here you need to inject your test case's schema
|
||||
GRAPHQL_SCHEMA = schema
|
||||
|
||||
def test_some_query(self):
|
||||
response = self.query(
|
||||
'''
|
||||
|
@ -28,7 +28,7 @@ Usage:
|
|||
}
|
||||
}
|
||||
''',
|
||||
op_name='myModel'
|
||||
operation_name='myModel'
|
||||
)
|
||||
|
||||
content = json.loads(response.content)
|
||||
|
@ -49,7 +49,7 @@ Usage:
|
|||
}
|
||||
}
|
||||
''',
|
||||
op_name='myModel',
|
||||
operation_name='myModel',
|
||||
variables={'id': 1}
|
||||
)
|
||||
|
||||
|
@ -73,7 +73,7 @@ Usage:
|
|||
}
|
||||
}
|
||||
''',
|
||||
op_name='myMutation',
|
||||
operation_name='myMutation',
|
||||
input_data={'my_field': 'foo', 'other_field': 'bar'}
|
||||
)
|
||||
|
||||
|
@ -82,3 +82,74 @@ Usage:
|
|||
|
||||
# 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'}
|
||||
)
|
||||
|
||||
# This validates the status code and if you get errors
|
||||
self.assertResponseNoErrors(response)
|
||||
|
||||
# Add some more asserts if you like
|
||||
...
|
||||
|
||||
Using pytest
|
||||
------------
|
||||
|
||||
To use pytest define a simple fixture using the query helper below
|
||||
|
||||
.. code:: python
|
||||
|
||||
# Create a fixture using the graphql_query helper and `client` fixture from `pytest-django`.
|
||||
import json
|
||||
import pytest
|
||||
from graphene_django.utils.testing import graphql_query
|
||||
|
||||
@pytest.fixture
|
||||
def client_query(client):
|
||||
def func(*args, **kwargs):
|
||||
return graphql_query(*args, **kwargs, client=client)
|
||||
|
||||
return func
|
||||
|
||||
# Test you query using the client_query fixture
|
||||
def test_some_query(client_query):
|
||||
response = client_query(
|
||||
'''
|
||||
query {
|
||||
myModel {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
''',
|
||||
operation_name='myModel'
|
||||
)
|
||||
|
||||
content = json.loads(response.content)
|
||||
assert 'errors' not in content
|
||||
|
|
|
@ -3,15 +3,11 @@ Basic Tutorial
|
|||
|
||||
Graphene Django has a number of additional features that are designed to make
|
||||
working with Django easy. Our primary focus in this tutorial is to give a good
|
||||
understanding of how to connect models from Django ORM to graphene object types.
|
||||
understanding of how to connect models from Django ORM to Graphene object types.
|
||||
|
||||
Set up the Django project
|
||||
-------------------------
|
||||
|
||||
You can find the entire project in ``examples/cookbook-plain``.
|
||||
|
||||
----
|
||||
|
||||
We will set up the project, create the following:
|
||||
|
||||
- A Django project called ``cookbook``
|
||||
|
@ -28,18 +24,18 @@ We will set up the project, create the following:
|
|||
source env/bin/activate # On Windows use `env\Scripts\activate`
|
||||
|
||||
# Install Django and Graphene with Django support
|
||||
pip install django
|
||||
pip install graphene_django
|
||||
pip install django graphene_django
|
||||
|
||||
# Set up a new project with a single application
|
||||
django-admin.py startproject cookbook . # Note the trailing '.' character
|
||||
django-admin startproject cookbook . # Note the trailing '.' character
|
||||
cd cookbook
|
||||
django-admin.py startapp ingredients
|
||||
django-admin startapp ingredients
|
||||
|
||||
Now sync your database for the first time:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
cd ..
|
||||
python manage.py migrate
|
||||
|
||||
Let's create a few simple models...
|
||||
|
@ -54,19 +50,18 @@ Let's get started with these models:
|
|||
# cookbook/ingredients/models.py
|
||||
from django.db import models
|
||||
|
||||
|
||||
class Category(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
|
||||
class Ingredient(models.Model):
|
||||
name = models.CharField(max_length=100)
|
||||
notes = models.TextField()
|
||||
category = models.ForeignKey(
|
||||
Category, related_name='ingredients', on_delete=models.CASCADE)
|
||||
Category, related_name="ingredients", on_delete=models.CASCADE
|
||||
)
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
@ -75,12 +70,26 @@ Add ingredients as INSTALLED_APPS:
|
|||
|
||||
.. code:: python
|
||||
|
||||
# cookbook/settings.py
|
||||
|
||||
INSTALLED_APPS = [
|
||||
...
|
||||
# Install the ingredients app
|
||||
'cookbook.ingredients',
|
||||
"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:
|
||||
|
||||
|
@ -95,20 +104,20 @@ 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:
|
||||
|
||||
.. code:: bash
|
||||
|
||||
$ python ./manage.py loaddata ingredients
|
||||
python manage.py loaddata ingredients
|
||||
|
||||
Installed 6 object(s) from 1 fixture(s)
|
||||
|
||||
Alternatively you can use the Django admin interface to create some data
|
||||
yourself. You'll need to run the development server (see below), and
|
||||
create a login for yourself too (``./manage.py createsuperuser``).
|
||||
create a login for yourself too (``python manage.py createsuperuser``).
|
||||
|
||||
Register models with admin panel:
|
||||
|
||||
|
@ -138,68 +147,48 @@ order to create this representation, Graphene needs to know about each
|
|||
This graph also has a *root type* through which all access begins. This
|
||||
is the ``Query`` class below.
|
||||
|
||||
This means, for each of our models, we are going to create a type, subclassing ``DjangoObjectType``
|
||||
To create GraphQL types for each of our Django models, we are going to subclass the ``DjangoObjectType`` class which will automatically define GraphQL fields that correspond to the fields on the Django models.
|
||||
|
||||
After we've done that, we will list those types as fields in the ``Query`` class.
|
||||
|
||||
Create ``cookbook/ingredients/schema.py`` and type the following:
|
||||
Create ``cookbook/schema.py`` and type the following:
|
||||
|
||||
.. code:: python
|
||||
|
||||
# cookbook/ingredients/schema.py
|
||||
# cookbook/schema.py
|
||||
import graphene
|
||||
|
||||
from graphene_django.types import DjangoObjectType
|
||||
from graphene_django import DjangoObjectType
|
||||
|
||||
from cookbook.ingredients.models import Category, Ingredient
|
||||
|
||||
|
||||
class CategoryType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Category
|
||||
fields = '__all__'
|
||||
|
||||
fields = ("id", "name", "ingredients")
|
||||
|
||||
class IngredientType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Ingredient
|
||||
fields = '__all__'
|
||||
fields = ("id", "name", "notes", "category")
|
||||
|
||||
|
||||
class Query(object):
|
||||
all_categories = graphene.List(CategoryType)
|
||||
class Query(graphene.ObjectType):
|
||||
all_ingredients = graphene.List(IngredientType)
|
||||
category_by_name = graphene.Field(CategoryType, name=graphene.String(required=True))
|
||||
|
||||
def resolve_all_categories(self, info, **kwargs):
|
||||
return Category.objects.all()
|
||||
|
||||
def resolve_all_ingredients(self, info, **kwargs):
|
||||
def resolve_all_ingredients(root, info):
|
||||
# We can easily optimize query count in the resolve method
|
||||
return Ingredient.objects.select_related('category').all()
|
||||
return Ingredient.objects.select_related("category").all()
|
||||
|
||||
|
||||
Note that the above ``Query`` class is a mixin, inheriting from
|
||||
``object``. This is because we will now create a project-level query
|
||||
class which will combine all our app-level mixins.
|
||||
|
||||
Create the parent project-level ``cookbook/schema.py``:
|
||||
|
||||
.. code:: python
|
||||
|
||||
import graphene
|
||||
|
||||
import cookbook.ingredients.schema
|
||||
|
||||
|
||||
class Query(cookbook.ingredients.schema.Query, graphene.ObjectType):
|
||||
# This class will inherit from multiple Queries
|
||||
# as we begin to add more apps to our project
|
||||
pass
|
||||
def resolve_category_by_name(root, info, name):
|
||||
try:
|
||||
return Category.objects.get(name=name)
|
||||
except Category.DoesNotExist:
|
||||
return None
|
||||
|
||||
schema = graphene.Schema(query=Query)
|
||||
|
||||
You can think of this as being something like your top-level ``urls.py``
|
||||
file (although it currently lacks any namespacing).
|
||||
file.
|
||||
|
||||
Testing everything so far
|
||||
-------------------------
|
||||
|
@ -218,18 +207,21 @@ Add ``graphene_django`` to ``INSTALLED_APPS`` in ``cookbook/settings.py``:
|
|||
|
||||
.. code:: python
|
||||
|
||||
# cookbook/settings.py
|
||||
|
||||
INSTALLED_APPS = [
|
||||
...
|
||||
# This will also make the `graphql_schema` management command available
|
||||
'graphene_django',
|
||||
"graphene_django",
|
||||
]
|
||||
|
||||
And then add the ``SCHEMA`` to the ``GRAPHENE`` config in ``cookbook/settings.py``:
|
||||
|
||||
.. code:: python
|
||||
|
||||
# cookbook/settings.py
|
||||
|
||||
GRAPHENE = {
|
||||
'SCHEMA': 'cookbook.schema.schema'
|
||||
"SCHEMA": "cookbook.schema.schema"
|
||||
}
|
||||
|
||||
Alternatively, we can specify the schema to be used in the urls definition,
|
||||
|
@ -247,14 +239,17 @@ aforementioned GraphiQL we specify that on the parameters with ``graphiql=True``
|
|||
|
||||
.. code:: python
|
||||
|
||||
from django.conf.urls import url, include
|
||||
# cookbook/urls.py
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from graphene_django.views import GraphQLView
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^admin/', admin.site.urls),
|
||||
url(r'^graphql$', GraphQLView.as_view(graphiql=True)),
|
||||
path("admin/", admin.site.urls),
|
||||
path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True))),
|
||||
]
|
||||
|
||||
|
||||
|
@ -263,16 +258,19 @@ as explained above, we can do so here using:
|
|||
|
||||
.. code:: python
|
||||
|
||||
from django.conf.urls import url, include
|
||||
# cookbook/urls.py
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from graphene_django.views import GraphQLView
|
||||
|
||||
from cookbook.schema import schema
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^admin/', admin.site.urls),
|
||||
url(r'^graphql$', GraphQLView.as_view(graphiql=True, schema=schema)),
|
||||
path("admin/", admin.site.urls),
|
||||
path("graphql", csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema))),
|
||||
]
|
||||
|
||||
|
||||
|
@ -285,10 +283,10 @@ from the command line.
|
|||
|
||||
.. code:: bash
|
||||
|
||||
$ python ./manage.py runserver
|
||||
python manage.py runserver
|
||||
|
||||
Performing system checks...
|
||||
Django version 1.11, using settings 'cookbook.settings'
|
||||
Django version 3.0.7, using settings 'cookbook.settings'
|
||||
Starting development server at http://127.0.0.1:8000/
|
||||
Quit the server with CONTROL-C.
|
||||
|
||||
|
@ -331,24 +329,25 @@ If you are using the provided fixtures, you will see the following response:
|
|||
}
|
||||
}
|
||||
|
||||
You can experiment with ``allCategories`` too.
|
||||
|
||||
Something to have in mind is the `auto camelcasing <http://docs.graphene-python.org/en/latest/types/schema/#auto-camelcase-field-names>`__ that is happening.
|
||||
Congratulations, you have created a working GraphQL server 🥳!
|
||||
|
||||
Note: Graphene `automatically camelcases <http://docs.graphene-python.org/en/latest/types/schema/#auto-camelcase-field-names>`__ all field names for better compatibility with JavaScript clients.
|
||||
|
||||
|
||||
Getting relations
|
||||
-----------------
|
||||
|
||||
Right now, with this simple setup in place, we can query for relations too. This is where graphql becomes really powerful!
|
||||
Using the current schema we can query for relations too. This is where GraphQL becomes really powerful!
|
||||
|
||||
For example, we may want to list all categories and in each category, all ingredients that are in that category.
|
||||
For example, we may want to get a specific categories and list all ingredients that are in that category.
|
||||
|
||||
We can do that with the following query:
|
||||
|
||||
.. code::
|
||||
|
||||
query {
|
||||
allCategories {
|
||||
categoryByName(name: "Dairy") {
|
||||
id
|
||||
name
|
||||
ingredients {
|
||||
|
@ -358,43 +357,26 @@ We can do that with the following query:
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
This will give you (in case you are using the fixtures) the following result:
|
||||
|
||||
.. code::
|
||||
|
||||
{
|
||||
"data": {
|
||||
"allCategories": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Dairy",
|
||||
"ingredients": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Eggs"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Milk"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Meat",
|
||||
"ingredients": [
|
||||
{
|
||||
"id": "3",
|
||||
"name": "Beef"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"name": "Chicken"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
"categoryByName": {
|
||||
"id": "1",
|
||||
"name": "Dairy",
|
||||
"ingredients": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Eggs"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Milk"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -413,71 +395,12 @@ We can also list all ingredients and get information for the category they are i
|
|||
}
|
||||
}
|
||||
|
||||
Getting single objects
|
||||
----------------------
|
||||
|
||||
So far, we have been able to fetch list of objects and follow relation. But what about single objects?
|
||||
|
||||
We can update our schema to support that, by adding new query for ``ingredient`` and ``category`` and adding arguments, so we can query for specific objects.
|
||||
Add the **Highlighted** lines to ``cookbook/ingredients/schema.py``
|
||||
|
||||
.. literalinclude:: schema.py
|
||||
:emphasize-lines: 19-21,25-27,36-58
|
||||
|
||||
Now, with the code in place, we can query for single objects.
|
||||
|
||||
For example, lets query ``category``:
|
||||
|
||||
|
||||
.. code::
|
||||
|
||||
query {
|
||||
category(id: 1) {
|
||||
name
|
||||
}
|
||||
anotherCategory: category(name: "Dairy") {
|
||||
ingredients {
|
||||
id
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
This will give us the following results:
|
||||
|
||||
.. code::
|
||||
|
||||
{
|
||||
"data": {
|
||||
"category": {
|
||||
"name": "Dairy"
|
||||
},
|
||||
"anotherCategory": {
|
||||
"ingredients": [
|
||||
{
|
||||
"id": "1",
|
||||
"name": "Eggs"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "Milk"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
As an exercise, you can try making some queries to ``ingredient``.
|
||||
|
||||
Something to keep in mind - since we are using one field several times in our query, we need `aliases <http://graphql.org/learn/queries/#aliases>`__
|
||||
|
||||
|
||||
Summary
|
||||
-------
|
||||
|
||||
As you can see, GraphQL is very powerful but there are a lot of repetitions in our example. We can do a lot of improvements by adding layers of abstraction on top of ``graphene-django``.
|
||||
As you can see, GraphQL is very powerful and integrating Django models allows you to get started with a working server quickly.
|
||||
|
||||
If you want to put things like ``django-filter`` and automatic pagination in action, you should continue with the **relay tutorial.**
|
||||
If you want to put things like ``django-filter`` and automatic pagination in action, you should continue with the :ref:`Relay tutorial`.
|
||||
|
||||
A good idea is to check the `graphene <http://docs.graphene-python.org/en/latest/>`__
|
||||
documentation but it is not essential to understand and use Graphene-Django in your project.
|
||||
A good idea is to check the `Graphene <http://docs.graphene-python.org/en/latest/>`__
|
||||
documentation so that you are familiar with it as well.
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
.. _Relay tutorial:
|
||||
|
||||
Relay tutorial
|
||||
========================================
|
||||
|
||||
|
@ -5,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
|
||||
------------------------
|
||||
|
@ -68,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
|
||||
|
@ -85,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:
|
||||
|
@ -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()),
|
||||
]
|
0
examples/__init__.py
Normal file
0
examples/__init__.py
Normal file
|
@ -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
|
||||
```
|
||||
|
|
0
examples/cookbook-plain/__init__.py
Normal file
0
examples/cookbook-plain/__init__.py
Normal file
|
@ -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"},
|
||||
),
|
||||
]
|
||||
|
|
|
@ -16,7 +16,7 @@ class IngredientType(DjangoObjectType):
|
|||
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,
|
||||
),
|
||||
),
|
||||
]
|
||||
|
|
|
@ -16,7 +16,7 @@ class RecipeIngredientType(DjangoObjectType):
|
|||
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.3
|
||||
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)
|
||||
|
|
0
examples/cookbook/__init__.py
Normal file
0
examples/cookbook/__init__.py
Normal file
|
@ -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)
|
||||
|
@ -28,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,8 +1,9 @@
|
|||
from cookbook.recipes.models import Recipe, RecipeIngredient
|
||||
from graphene import Node
|
||||
from graphene_django.filter import DjangoFilterConnectionField
|
||||
from graphene_django.types import DjangoObjectType
|
||||
|
||||
from cookbook.recipes.models import Recipe, RecipeIngredient
|
||||
|
||||
|
||||
class RecipeNode(DjangoObjectType):
|
||||
class Meta:
|
||||
|
@ -25,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.3
|
||||
django==3.2.25
|
||||
django-filter>=2
|
||||
|
|
32
examples/django_test_settings.py
Normal file
32
examples/django_test_settings.py
Normal file
|
@ -0,0 +1,32 @@
|
|||
import os
|
||||
import sys
|
||||
|
||||
ROOT_PATH = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, ROOT_PATH + "/examples/")
|
||||
|
||||
SECRET_KEY = 1
|
||||
|
||||
INSTALLED_APPS = [
|
||||
"graphene_django",
|
||||
"graphene_django.rest_framework",
|
||||
"graphene_django.tests",
|
||||
"examples.starwars",
|
||||
]
|
||||
|
||||
DATABASES = {
|
||||
"default": {"ENGINE": "django.db.backends.sqlite3", "NAME": "django_test.sqlite"}
|
||||
}
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||
"DIRS": [],
|
||||
"APP_DIRS": True,
|
||||
}
|
||||
]
|
||||
|
||||
GRAPHENE = {"SCHEMA": "graphene_django.tests.schema_view.schema"}
|
||||
|
||||
ROOT_URLCONF = "graphene_django.tests.urls"
|
||||
|
||||
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,11 +1,13 @@
|
|||
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):
|
||||
|
@ -60,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__ = "3.0.0b3"
|
||||
__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,8 +19,47 @@ try:
|
|||
from django.contrib.postgres.fields import (
|
||||
ArrayField,
|
||||
HStoreField,
|
||||
JSONField,
|
||||
IntegerRangeField,
|
||||
RangeField,
|
||||
)
|
||||
except ImportError:
|
||||
ArrayField, HStoreField, JSONField, 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:
|
||||
from django.utils.choices import normalize_choices
|
||||
except ImportError:
|
||||
|
||||
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
graphene_django/constants.py
Normal file
1
graphene_django/constants.py
Normal file
|
@ -0,0 +1 @@
|
|||
MUTATION_ERRORS_FLAG = "graphene_mutation_has_errors"
|
|
@ -1,16 +1,19 @@
|
|||
from collections import OrderedDict
|
||||
from functools import singledispatch
|
||||
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,
|
||||
|
@ -22,19 +25,63 @@ from graphene import (
|
|||
Time,
|
||||
)
|
||||
from graphene.types.json import JSONString
|
||||
from graphene.utils.str_converters import to_camel_case, to_const
|
||||
from graphql import GraphQLError, assert_valid_name
|
||||
from graphene.types.resolver import get_default_resolver
|
||||
from graphene.types.scalars import BigInt
|
||||
from graphene.utils.str_converters import to_camel_case
|
||||
|
||||
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, JSONField, RangeField
|
||||
from .compat import ArrayField, HStoreField, RangeField, normalize_choices
|
||||
from .fields import DjangoConnectionField, DjangoListField
|
||||
from .settings import graphene_settings
|
||||
from .utils.str_converters import to_const
|
||||
|
||||
|
||||
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)
|
||||
assert_name(name)
|
||||
except GraphQLError:
|
||||
name = "A_%s" % name
|
||||
return name
|
||||
|
@ -42,12 +89,10 @@ def convert_choice_name(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:
|
||||
|
@ -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 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):
|
||||
|
@ -80,7 +131,7 @@ def generate_enum_name(django_model_meta, field):
|
|||
)
|
||||
name = custom_func(field)
|
||||
elif graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V2_NAMING is True:
|
||||
name = to_camel_case("{}_{}".format(django_model_meta.object_name, field.name))
|
||||
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()),
|
||||
|
@ -98,19 +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 choices and convert_choices_to_enum:
|
||||
enum = convert_choice_field_to_enum(field)
|
||||
required = not (field.blank or field.null)
|
||||
converted = enum(
|
||||
description=get_django_field_description(field), required=required
|
||||
if convert_choices_to_enum is None:
|
||||
convert_choices_to_enum = bool(
|
||||
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CONVERT
|
||||
)
|
||||
if choices and convert_choices_to_enum:
|
||||
EnumCls = convert_choice_field_to_enum(field)
|
||||
required = not (field.blank or field.null)
|
||||
|
||||
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:
|
||||
|
@ -125,7 +181,7 @@ def get_django_field_description(field):
|
|||
@singledispatch
|
||||
def convert_django_field(field, registry=None):
|
||||
raise Exception(
|
||||
"Don't know how to convert the Django field %s (%s)" % (field, field.__class__)
|
||||
f"Don't know how to convert the Django field {field} ({field.__class__})"
|
||||
)
|
||||
|
||||
|
||||
|
@ -144,6 +200,8 @@ def convert_field_to_string(field, registry=None):
|
|||
|
||||
|
||||
@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=get_django_field_description(field), required=not field.null)
|
||||
|
||||
|
@ -155,10 +213,14 @@ def convert_field_to_uuid(field, registry=None):
|
|||
)
|
||||
|
||||
|
||||
@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=get_django_field_description(field), required=not field.null)
|
||||
|
@ -173,6 +235,12 @@ def convert_field_to_boolean(field, registry=None):
|
|||
|
||||
|
||||
@convert_django_field.register(models.DecimalField)
|
||||
def convert_field_to_decimal(field, registry=None):
|
||||
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):
|
||||
|
@ -204,6 +272,10 @@ def convert_time_to_string(field, registry=None):
|
|||
|
||||
@convert_django_field.register(models.OneToOneRel)
|
||||
def convert_onetoone_field_to_djangomodel(field, registry=None):
|
||||
from graphene.utils.str_converters import to_snake_case
|
||||
|
||||
from .types import DjangoObjectType
|
||||
|
||||
model = field.related_model
|
||||
|
||||
def dynamic_type():
|
||||
|
@ -211,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)
|
||||
|
||||
|
@ -262,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():
|
||||
|
@ -269,7 +390,79 @@ def convert_field_to_djangomodel(field, registry=None):
|
|||
if not _type:
|
||||
return
|
||||
|
||||
return Field(
|
||||
class CustomField(Field):
|
||||
def wrap_resolve(self, parent_resolver):
|
||||
"""
|
||||
Implements a custom resolver which goes through the `get_node` method to ensure that
|
||||
it goes through the `get_queryset` method of the DjangoObjectType.
|
||||
"""
|
||||
resolver = super().wrap_resolve(parent_resolver)
|
||||
|
||||
# If `get_queryset` was not overridden in the DjangoObjectType
|
||||
# or if we explicitly bypass the `get_queryset` method,
|
||||
# we can just return the default resolver.
|
||||
if (
|
||||
_type.get_queryset.__func__
|
||||
is DjangoObjectType.get_queryset.__func__
|
||||
or getattr(resolver, "_bypass_get_queryset", False)
|
||||
):
|
||||
return resolver
|
||||
|
||||
def custom_resolver(root, info, **args):
|
||||
# Note: this function is used to resolve FK or 1:1 fields
|
||||
# it does not differentiate between custom-resolved fields
|
||||
# and default resolved fields.
|
||||
|
||||
# because this is a django foreign key or one-to-one field, the primary-key for
|
||||
# this node can be accessed from the root node.
|
||||
# ex: article.reporter_id
|
||||
|
||||
# get the name of the id field from the root's model
|
||||
field_name = to_snake_case(info.field_name)
|
||||
db_field_key = root.__class__._meta.get_field(field_name).attname
|
||||
if hasattr(root, db_field_key):
|
||||
# get the object's primary-key from root
|
||||
object_pk = getattr(root, db_field_key)
|
||||
else:
|
||||
return None
|
||||
|
||||
is_resolver_awaitable = inspect.iscoroutinefunction(resolver)
|
||||
|
||||
if is_resolver_awaitable:
|
||||
fk_obj = resolver(root, info, **args)
|
||||
# In case the resolver is a custom awaitable resolver that overwrites
|
||||
# the default Django resolver
|
||||
return fk_obj
|
||||
|
||||
instance_from_get_node = _type.get_node(info, object_pk)
|
||||
|
||||
if instance_from_get_node is None:
|
||||
# no instance to return
|
||||
return
|
||||
elif (
|
||||
isinstance(resolver, partial)
|
||||
and resolver.func is get_default_resolver()
|
||||
):
|
||||
return instance_from_get_node
|
||||
elif resolver is not get_default_resolver():
|
||||
# Default resolver is overridden
|
||||
# For optimization, add the instance to the resolver
|
||||
setattr(root, field_name, instance_from_get_node)
|
||||
# Explanation:
|
||||
# previously, _type.get_node` is called which results in at least one hit to the database.
|
||||
# But, if we did not pass the instance to the root, calling the resolver will result in
|
||||
# another call to get the instance which results in at least two database queries in total
|
||||
# to resolve this node only.
|
||||
# That's why the value of the object is set in the root so when the object is accessed
|
||||
# in the resolver (root.field_name) it does not access the database unless queried explicitly.
|
||||
fk_obj = resolver(root, info, **args)
|
||||
return fk_obj
|
||||
else:
|
||||
return instance_from_get_node
|
||||
|
||||
return custom_resolver
|
||||
|
||||
return CustomField(
|
||||
_type,
|
||||
description=get_django_field_description(field),
|
||||
required=not field.null,
|
||||
|
@ -280,19 +473,23 @@ def convert_field_to_djangomodel(field, registry=None):
|
|||
|
||||
@convert_django_field.register(ArrayField)
|
||||
def convert_postgres_array_to_list(field, registry=None):
|
||||
base_type = convert_django_field(field.base_field)
|
||||
if not isinstance(base_type, (List, NonNull)):
|
||||
base_type = type(base_type)
|
||||
inner_type = convert_django_field(field.base_field)
|
||||
if not isinstance(inner_type, (List, NonNull)):
|
||||
inner_type = (
|
||||
NonNull(type(inner_type))
|
||||
if inner_type.kwargs["required"]
|
||||
else type(inner_type)
|
||||
)
|
||||
return List(
|
||||
base_type,
|
||||
inner_type,
|
||||
description=get_django_field_description(field),
|
||||
required=not field.null,
|
||||
)
|
||||
|
||||
|
||||
@convert_django_field.register(HStoreField)
|
||||
@convert_django_field.register(JSONField)
|
||||
def convert_postgres_field_to_string(field, registry=None):
|
||||
@convert_django_field.register(models.JSONField)
|
||||
def convert_json_field_to_string(field, registry=None):
|
||||
return JSONString(
|
||||
description=get_django_field_description(field), required=not field.null
|
||||
)
|
||||
|
@ -302,7 +499,11 @@ def convert_postgres_field_to_string(field, registry=None):
|
|||
def convert_postgres_range_to_string(field, registry=None):
|
||||
inner_type = convert_django_field(field.base_field)
|
||||
if not isinstance(inner_type, (List, NonNull)):
|
||||
inner_type = type(inner_type)
|
||||
inner_type = (
|
||||
NonNull(type(inner_type))
|
||||
if inner_type.kwargs["required"]
|
||||
else type(inner_type)
|
||||
)
|
||||
return List(
|
||||
inner_type,
|
||||
description=get_django_field_description(field),
|
||||
|
|
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).get()
|
||||
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,5 +1,4 @@
|
|||
# Code obtained from django-debug-toolbar sql panel tracking
|
||||
from __future__ import absolute_import, unicode_literals
|
||||
|
||||
import json
|
||||
from threading import local
|
||||
|
@ -50,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.
|
||||
|
@ -63,7 +62,7 @@ class ExceptionCursorWrapper(object):
|
|||
raise SQLQueryTriggered()
|
||||
|
||||
|
||||
class NormalCursorWrapper(object):
|
||||
class NormalCursorWrapper:
|
||||
"""
|
||||
Wraps a cursor and logs queries.
|
||||
"""
|
||||
|
@ -85,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,3 +1,5 @@
|
|||
import pytest
|
||||
|
||||
import graphene
|
||||
from graphene.relay import Node
|
||||
from graphene_django import DjangoConnectionField, DjangoObjectType
|
||||
|
@ -7,7 +9,7 @@ from ..middleware import DjangoDebugMiddleware
|
|||
from ..types import DjangoDebug
|
||||
|
||||
|
||||
class context(object):
|
||||
class context:
|
||||
pass
|
||||
|
||||
|
||||
|
@ -54,7 +56,10 @@ def test_should_query_field():
|
|||
assert result.data == expected
|
||||
|
||||
|
||||
def test_should_query_nested_field():
|
||||
@pytest.mark.parametrize("max_limit", [None, 100])
|
||||
def test_should_query_nested_field(graphene_settings, max_limit):
|
||||
graphene_settings.RELAY_CONNECTION_MAX_LIMIT = max_limit
|
||||
|
||||
r1 = Reporter(last_name="ABA")
|
||||
r1.save()
|
||||
r2 = Reporter(last_name="Griffin")
|
||||
|
@ -165,7 +170,10 @@ def test_should_query_list():
|
|||
assert result.data == expected
|
||||
|
||||
|
||||
def test_should_query_connection():
|
||||
@pytest.mark.parametrize("max_limit", [None, 100])
|
||||
def test_should_query_connection(graphene_settings, max_limit):
|
||||
graphene_settings.RELAY_CONNECTION_MAX_LIMIT = max_limit
|
||||
|
||||
r1 = Reporter(last_name="ABA")
|
||||
r1.save()
|
||||
r2 = Reporter(last_name="Griffin")
|
||||
|
@ -207,12 +215,16 @@ def test_should_query_connection():
|
|||
)
|
||||
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"]
|
||||
query = str(Reporter.objects.all()[:1].query)
|
||||
assert result.data["_debug"]["sql"][1]["rawSql"] == query
|
||||
|
||||
|
||||
def test_should_query_connectionfilter():
|
||||
@pytest.mark.parametrize("max_limit", [None, 100])
|
||||
def test_should_query_connectionfilter(graphene_settings, max_limit):
|
||||
graphene_settings.RELAY_CONNECTION_MAX_LIMIT = max_limit
|
||||
|
||||
from ...filter import DjangoFilterConnectionField
|
||||
|
||||
r1 = Reporter(last_name="ABA")
|
||||
|
@ -257,6 +269,46 @@ def test_should_query_connectionfilter():
|
|||
)
|
||||
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"]
|
||||
query = str(Reporter.objects.all()[:1].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,10 +1,15 @@
|
|||
from functools import partial
|
||||
|
||||
from django.db.models.query import QuerySet
|
||||
from graphql_relay.connection.arrayconnection import connection_from_array_slice
|
||||
from graphql_relay import (
|
||||
connection_from_array_slice,
|
||||
cursor_to_offset,
|
||||
get_offset_with_default,
|
||||
offset_to_cursor,
|
||||
)
|
||||
from promise import Promise
|
||||
|
||||
from graphene import NonNull
|
||||
from graphene import Int, NonNull
|
||||
from graphene.relay import ConnectionField
|
||||
from graphene.relay.connection import connection_adapter, page_info_adapter
|
||||
from graphene.types import Field, List
|
||||
|
@ -15,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):
|
||||
|
@ -38,16 +46,16 @@ class DjangoListField(Field):
|
|||
def model(self):
|
||||
return self._underlying_type._meta.model
|
||||
|
||||
def get_default_queryset(self):
|
||||
return self.model._default_manager.get_queryset()
|
||||
def get_manager(self):
|
||||
return self.model._default_manager
|
||||
|
||||
@staticmethod
|
||||
def list_resolver(
|
||||
django_object_type, resolver, default_queryset, root, info, **args
|
||||
django_object_type, resolver, default_manager, root, info, **args
|
||||
):
|
||||
queryset = maybe_queryset(resolver(root, info, **args))
|
||||
if queryset is None:
|
||||
queryset = default_queryset
|
||||
queryset = maybe_queryset(default_manager)
|
||||
|
||||
if isinstance(queryset, QuerySet):
|
||||
# Pass queryset to the DjangoObjectType get_queryset method
|
||||
|
@ -55,7 +63,8 @@ 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
|
||||
|
@ -63,8 +72,8 @@ class DjangoListField(Field):
|
|||
return partial(
|
||||
self.list_resolver,
|
||||
django_object_type,
|
||||
parent_resolver,
|
||||
self.get_default_queryset(),
|
||||
resolver,
|
||||
self.get_manager(),
|
||||
)
|
||||
|
||||
|
||||
|
@ -78,7 +87,8 @@ class DjangoConnectionField(ConnectionField):
|
|||
"enforce_first_or_last",
|
||||
graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST,
|
||||
)
|
||||
super(DjangoConnectionField, self).__init__(*args, **kwargs)
|
||||
kwargs.setdefault("offset", Int())
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def type(self):
|
||||
|
@ -127,24 +137,53 @@ class DjangoConnectionField(ConnectionField):
|
|||
return connection._meta.node.get_queryset(queryset, info)
|
||||
|
||||
@classmethod
|
||||
def resolve_connection(cls, connection, args, iterable):
|
||||
def resolve_connection(cls, connection, args, iterable, max_limit=None):
|
||||
# Remove the offset parameter and convert it to an after cursor.
|
||||
offset = args.pop("offset", None)
|
||||
after = args.get("after")
|
||||
if offset:
|
||||
if after:
|
||||
offset += cursor_to_offset(after) + 1
|
||||
# input offset starts at 1 while the graphene offset starts at 0
|
||||
args["after"] = offset_to_cursor(offset - 1)
|
||||
|
||||
iterable = maybe_queryset(iterable)
|
||||
|
||||
if isinstance(iterable, QuerySet):
|
||||
_len = iterable.count()
|
||||
array_length = iterable.count()
|
||||
else:
|
||||
_len = len(iterable)
|
||||
array_length = len(iterable)
|
||||
|
||||
# If after is higher than array_length, connection_from_array_slice
|
||||
# would try to do a negative slicing which makes django throw an
|
||||
# AssertionError
|
||||
slice_start = min(
|
||||
get_offset_with_default(args.get("after"), -1) + 1,
|
||||
array_length,
|
||||
)
|
||||
array_slice_length = array_length - slice_start
|
||||
|
||||
# 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_array_slice(
|
||||
iterable,
|
||||
iterable[slice_start:],
|
||||
args,
|
||||
slice_start=0,
|
||||
array_length=_len,
|
||||
array_slice_length=_len,
|
||||
slice_start=slice_start,
|
||||
array_length=array_length,
|
||||
array_slice_length=array_slice_length,
|
||||
connection_type=partial(connection_adapter, connection),
|
||||
edge_type=connection.Edge,
|
||||
page_info_type=page_info_adapter,
|
||||
)
|
||||
connection.iterable = iterable
|
||||
connection.length = _len
|
||||
connection.length = array_length
|
||||
return connection
|
||||
|
||||
@classmethod
|
||||
|
@ -158,10 +197,12 @@ class DjangoConnectionField(ConnectionField):
|
|||
enforce_first_or_last,
|
||||
root,
|
||||
info,
|
||||
**args
|
||||
**args,
|
||||
):
|
||||
first = args.get("first")
|
||||
last = args.get("last")
|
||||
offset = args.get("offset")
|
||||
before = args.get("before")
|
||||
|
||||
if enforce_first_or_last:
|
||||
assert first or last, (
|
||||
|
@ -181,6 +222,11 @@ class DjangoConnectionField(ConnectionField):
|
|||
).format(last, info.field_name, max_limit)
|
||||
args["last"] = min(last, max_limit)
|
||||
|
||||
if offset is not None:
|
||||
assert before is None, (
|
||||
"You can't provide a `before` value at the same time as an `offset` value to properly paginate the `{}` connection."
|
||||
).format(info.field_name)
|
||||
|
||||
# eventually leads to DjangoObjectType's get_queryset (accepts queryset)
|
||||
# or a resolve_foo (does not accept queryset)
|
||||
iterable = resolver(root, info, **args)
|
||||
|
@ -189,17 +235,19 @@ class DjangoConnectionField(ConnectionField):
|
|||
# thus the iterable gets refiltered by resolve_queryset
|
||||
# but iterable might be promise
|
||||
iterable = queryset_resolver(connection, iterable, info, args)
|
||||
on_resolve = partial(cls.resolve_connection, connection, args)
|
||||
on_resolve = partial(
|
||||
cls.resolve_connection, connection, args, max_limit=max_limit
|
||||
)
|
||||
|
||||
if Promise.is_thenable(iterable):
|
||||
return Promise.resolve(iterable).then(on_resolve)
|
||||
|
||||
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",
|
||||
]
|
||||
|
|
|
@ -1,28 +1,50 @@
|
|||
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
|
||||
self._filterset_class = None
|
||||
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):
|
||||
|
@ -36,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)
|
||||
|
||||
|
@ -49,17 +71,33 @@ class DjangoFilterConnectionField(DjangoConnectionField):
|
|||
|
||||
@property
|
||||
def filtering_args(self):
|
||||
return get_filtering_args_from_filterset(self.filterset_class, self.node_type)
|
||||
if not self._filtering_args:
|
||||
self._filtering_args = get_filtering_args_from_filterset(
|
||||
self.filterset_class, self.node_type
|
||||
)
|
||||
return self._filtering_args
|
||||
|
||||
@classmethod
|
||||
def resolve_queryset(
|
||||
cls, connection, iterable, info, args, filtering_args, filterset_class
|
||||
):
|
||||
qs = super(DjangoFilterConnectionField, cls).resolve_queryset(
|
||||
connection, iterable, info, args
|
||||
def filter_kwargs():
|
||||
kwargs = {}
|
||||
for k, v in args.items():
|
||||
if k in filtering_args:
|
||||
if k == "order_by" and v is not None:
|
||||
v = to_snake_case(v)
|
||||
kwargs[k] = convert_enum(v)
|
||||
return kwargs
|
||||
|
||||
qs = super().resolve_queryset(connection, iterable, info, args)
|
||||
|
||||
filterset = filterset_class(
|
||||
data=filter_kwargs(), queryset=qs, request=info.context
|
||||
)
|
||||
filter_kwargs = {k: v for k, v in args.items() if k in filtering_args}
|
||||
return filterset_class(data=filter_kwargs, queryset=qs, request=info.context).qs
|
||||
if filterset.is_valid():
|
||||
return filterset.qs
|
||||
raise ValidationError(filterset.form.errors.as_json())
|
||||
|
||||
def get_queryset_resolver(self):
|
||||
return partial(
|
||||
|
|
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
|
||||
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(
|
||||
|
@ -51,20 +31,18 @@ class GrapheneFilterSetMixin(BaseFilterSet):
|
|||
|
||||
|
||||
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",))
|
||||
|
@ -21,7 +21,7 @@ class ReporterFilter(django_filters.FilterSet):
|
|||
model = Reporter
|
||||
fields = ["first_name", "last_name", "email", "pets"]
|
||||
|
||||
order_by = OrderingFilter(fields=("pub_date",))
|
||||
order_by = OrderingFilter(fields=("first_name",))
|
||||
|
||||
|
||||
class PetFilter(django_filters.FilterSet):
|
||||
|
|
|
@ -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 (
|
||||
|
@ -62,12 +62,12 @@ def get_args(field):
|
|||
|
||||
|
||||
def assert_arguments(field, *arguments):
|
||||
ignore = ("after", "before", "first", "last", "order_by")
|
||||
ignore = ("offset", "after", "before", "first", "last", "order_by")
|
||||
args = get_args(field)
|
||||
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):
|
||||
|
@ -90,6 +90,7 @@ def test_filter_explicit_filterset_arguments():
|
|||
"pub_date__gt",
|
||||
"pub_date__lt",
|
||||
"reporter",
|
||||
"reporter__in",
|
||||
)
|
||||
|
||||
|
||||
|
@ -140,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):
|
||||
|
@ -165,7 +166,7 @@ def test_filter_shortcut_filterset_context():
|
|||
editor=r2,
|
||||
)
|
||||
|
||||
class context(object):
|
||||
class context:
|
||||
reporter = r2
|
||||
|
||||
query = """
|
||||
|
@ -221,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)
|
||||
|
@ -293,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)
|
||||
|
@ -400,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"
|
||||
|
||||
|
||||
|
@ -412,6 +413,118 @@ def test_global_id_field_relation():
|
|||
assert id_filter.field_class == GlobalIDFormField
|
||||
|
||||
|
||||
def test_global_id_field_relation_with_filter():
|
||||
class ReporterFilterNode(DjangoObjectType):
|
||||
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):
|
||||
all_reporters = DjangoFilterConnectionField(ReporterFilterNode)
|
||||
all_articles = DjangoFilterConnectionField(ArticleFilterNode)
|
||||
reporter = Field(ReporterFilterNode)
|
||||
article = Field(ArticleFilterNode)
|
||||
|
||||
r1 = Reporter.objects.create(first_name="r1", last_name="r1", email="r1@test.com")
|
||||
r2 = Reporter.objects.create(first_name="r2", last_name="r2", email="r2@test.com")
|
||||
Article.objects.create(
|
||||
headline="a1",
|
||||
pub_date=datetime.now(),
|
||||
pub_date_time=datetime.now(),
|
||||
reporter=r1,
|
||||
editor=r1,
|
||||
)
|
||||
Article.objects.create(
|
||||
headline="a2",
|
||||
pub_date=datetime.now(),
|
||||
pub_date_time=datetime.now(),
|
||||
reporter=r2,
|
||||
editor=r2,
|
||||
)
|
||||
|
||||
# Query articles created by the reporter `r1`
|
||||
query = """
|
||||
query {
|
||||
allArticles (reporter: "UmVwb3J0ZXJGaWx0ZXJOb2RlOjE=") {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
schema = Schema(query=Query)
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
# We should only get back a single article
|
||||
assert len(result.data["allArticles"]["edges"]) == 1
|
||||
|
||||
|
||||
def test_global_id_field_relation_with_filter_not_valid_id():
|
||||
class ReporterFilterNode(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
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):
|
||||
all_reporters = DjangoFilterConnectionField(ReporterFilterNode)
|
||||
all_articles = DjangoFilterConnectionField(ArticleFilterNode)
|
||||
reporter = Field(ReporterFilterNode)
|
||||
article = Field(ArticleFilterNode)
|
||||
|
||||
r1 = Reporter.objects.create(first_name="r1", last_name="r1", email="r1@test.com")
|
||||
r2 = Reporter.objects.create(first_name="r2", last_name="r2", email="r2@test.com")
|
||||
Article.objects.create(
|
||||
headline="a1",
|
||||
pub_date=datetime.now(),
|
||||
pub_date_time=datetime.now(),
|
||||
reporter=r1,
|
||||
editor=r1,
|
||||
)
|
||||
Article.objects.create(
|
||||
headline="a2",
|
||||
pub_date=datetime.now(),
|
||||
pub_date_time=datetime.now(),
|
||||
reporter=r2,
|
||||
editor=r2,
|
||||
)
|
||||
|
||||
# Filter by the global ID that does not exist
|
||||
query = """
|
||||
query {
|
||||
allArticles (reporter: "fake_global_id") {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
schema = Schema(query=Query)
|
||||
result = schema.execute(query)
|
||||
assert "Invalid ID specified." in result.errors[0].message
|
||||
|
||||
|
||||
def test_global_id_multiple_field_implicit():
|
||||
field = DjangoFilterConnectionField(ReporterNode, fields=["pets"])
|
||||
filterset_class = field.filterset_class
|
||||
|
@ -584,7 +697,7 @@ def test_should_query_filter_node_limit():
|
|||
node {
|
||||
id
|
||||
firstName
|
||||
articles(lang: "es") {
|
||||
articles(lang: ES) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
|
@ -621,7 +734,75 @@ def test_should_query_filter_node_limit():
|
|||
assert result.data == expected
|
||||
|
||||
|
||||
def test_order_by_is_perserved():
|
||||
def test_order_by():
|
||||
class ReporterType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Reporter
|
||||
interfaces = (Node,)
|
||||
fields = "__all__"
|
||||
|
||||
class Query(ObjectType):
|
||||
all_reporters = DjangoFilterConnectionField(
|
||||
ReporterType, filterset_class=ReporterFilter
|
||||
)
|
||||
|
||||
Reporter.objects.create(first_name="b")
|
||||
Reporter.objects.create(first_name="a")
|
||||
|
||||
schema = Schema(query=Query)
|
||||
query = """
|
||||
query NodeFilteringQuery {
|
||||
allReporters(orderBy: "-firstName") {
|
||||
edges {
|
||||
node {
|
||||
firstName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
expected = {
|
||||
"allReporters": {
|
||||
"edges": [{"node": {"firstName": "b"}}, {"node": {"firstName": "a"}}]
|
||||
}
|
||||
}
|
||||
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data == expected
|
||||
|
||||
query = """
|
||||
query NodeFilteringQuery {
|
||||
allReporters(orderBy: "-first_name") {
|
||||
edges {
|
||||
node {
|
||||
firstName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data == expected
|
||||
|
||||
query = """
|
||||
query NodeFilteringQuery {
|
||||
allReporters(orderBy: "-firstname") {
|
||||
edges {
|
||||
node {
|
||||
firstName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert result.errors
|
||||
|
||||
|
||||
def test_order_by_is_preserved():
|
||||
class ReporterType(DjangoObjectType):
|
||||
class Meta:
|
||||
model = Reporter
|
||||
|
@ -827,7 +1008,7 @@ def test_integer_field_filter_type():
|
|||
assert str(schema) == dedent(
|
||||
"""\
|
||||
type Query {
|
||||
pets(before: String = null, after: String = null, first: Int = null, last: Int = null, age: Int = null): PetTypeConnection
|
||||
pets(offset: Int, before: String, after: String, first: Int, last: Int, age: Int): PetTypeConnection
|
||||
}
|
||||
|
||||
type PetTypeConnection {
|
||||
|
@ -844,39 +1025,38 @@ def test_integer_field_filter_type():
|
|||
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!
|
||||
}
|
||||
|
||||
|
||||
\"""An object with an ID\"""
|
||||
interface Node {
|
||||
\"""The ID of the object\"""
|
||||
id: ID!
|
||||
}
|
||||
"""
|
||||
}"""
|
||||
)
|
||||
|
||||
|
||||
|
@ -896,13 +1076,13 @@ def test_other_filter_types():
|
|||
assert str(schema) == dedent(
|
||||
"""\
|
||||
type Query {
|
||||
pets(before: String = null, after: String = null, first: Int = null, last: Int = null, age: Int = null, age_Isnull: Boolean = null, age_Lt: Int = null): PetTypeConnection
|
||||
pets(offset: Int, before: String, after: String, first: Int, last: Int, age: Int, age_Isnull: Boolean, age_Lt: Int): PetTypeConnection
|
||||
}
|
||||
|
||||
type PetTypeConnection {
|
||||
\"""Pagination data for this connection.\"""
|
||||
pageInfo: PageInfo!
|
||||
|
||||
|
||||
\"""Contains the nodes in this connection.\"""
|
||||
edges: [PetTypeEdge]!
|
||||
}
|
||||
|
@ -928,14 +1108,14 @@ def test_other_filter_types():
|
|||
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!
|
||||
}
|
||||
|
@ -944,8 +1124,7 @@ def test_other_filter_types():
|
|||
interface Node {
|
||||
\"""The ID of the object\"""
|
||||
id: ID!
|
||||
}
|
||||
"""
|
||||
}"""
|
||||
)
|
||||
|
||||
|
||||
|
@ -964,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):
|
||||
|
@ -1007,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,
|
||||
|
@ -1017,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
|
||||
|
@ -1032,8 +1210,6 @@ def test_filter_filterset_based_on_mixin():
|
|||
}
|
||||
}
|
||||
"""
|
||||
% reporter_1.email
|
||||
)
|
||||
|
||||
expected = {
|
||||
"allArticles": {
|
||||
|
@ -1048,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")
|
||||
|
|
447
graphene_django/filter/tests/test_in_filter.py
Normal file
447
graphene_django/filter/tests/test_in_filter.py
Normal file
|
@ -0,0 +1,447 @@
|
|||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from django_filters import (
|
||||
FilterSet,
|
||||
rest_framework as filters,
|
||||
)
|
||||
|
||||
from graphene import ObjectType, Schema
|
||||
from graphene.relay import Node
|
||||
from graphene_django import DjangoObjectType
|
||||
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 = []
|
||||
|
||||
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 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
|
||||
|
||||
|
||||
def test_string_in_filter(query):
|
||||
"""
|
||||
Test in filter on a string field.
|
||||
"""
|
||||
Pet.objects.create(name="Brutus", age=12)
|
||||
Pet.objects.create(name="Mimi", age=3)
|
||||
Pet.objects.create(name="Jojo, the rabbit", age=3)
|
||||
|
||||
schema = Schema(query=query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
pets (name_In: ["Brutus", "Jojo, the rabbit"]) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["pets"]["edges"] == [
|
||||
{"node": {"name": "Brutus"}},
|
||||
{"node": {"name": "Jojo, the rabbit"}},
|
||||
]
|
||||
|
||||
|
||||
def test_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)
|
||||
|
||||
query = """
|
||||
query {
|
||||
people (name_In: ["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_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.
|
||||
"""
|
||||
Pet.objects.create(name="Brutus", age=12)
|
||||
Pet.objects.create(name="Mimi", age=3)
|
||||
Pet.objects.create(name="Jojo, the rabbit", age=3)
|
||||
|
||||
schema = Schema(query=query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
pets (age_In: [3]) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["pets"]["edges"] == [
|
||||
{"node": {"name": "Mimi"}},
|
||||
{"node": {"name": "Jojo, the rabbit"}},
|
||||
]
|
||||
|
||||
query = """
|
||||
query {
|
||||
pets (age_In: [3, 12]) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
assert result.data["pets"]["edges"] == [
|
||||
{"node": {"name": "Brutus"}},
|
||||
{"node": {"name": "Mimi"}},
|
||||
{"node": {"name": "Jojo, the rabbit"}},
|
||||
]
|
||||
|
||||
|
||||
def test_in_filter_with_empty_list(query):
|
||||
"""
|
||||
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="Picotin", age=5)
|
||||
|
||||
schema = Schema(query=query)
|
||||
|
||||
query = """
|
||||
query {
|
||||
pets (name_In: []) {
|
||||
edges {
|
||||
node {
|
||||
name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
result = schema.execute(query)
|
||||
assert not result.errors
|
||||
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"}},
|
||||
]
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user