mirror of
https://github.com/graphql-python/graphene-django.git
synced 2025-06-30 18:33:16 +03:00
Compare commits
92 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
f02ea337a2 | ||
|
ad26bfa2f6 | ||
|
788a20490a | ||
|
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 |
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:**
|
* **Please tell us about your environment:**
|
||||||
|
|
||||||
- Version:
|
- Version:
|
||||||
- Platform:
|
- Platform:
|
||||||
|
|
||||||
* **Other information** (e.g. detailed explanation, stacktraces, related issues, suggestions how to fix, links for us to have context, eg. stackoverflow)
|
* **Other information** (e.g. detailed explanation, stacktraces, related issues, suggestions how to fix, links for us to have context, eg. stackoverflow)
|
||||||
|
|
17
.github/workflows/deploy.yml
vendored
17
.github/workflows/deploy.yml
vendored
|
@ -6,21 +6,26 @@ on:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
lint:
|
||||||
|
uses: ./.github/workflows/lint.yml
|
||||||
|
tests:
|
||||||
|
uses: ./.github/workflows/tests.yml
|
||||||
|
release:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
needs: [lint, tests]
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python 3.9
|
- name: Set up Python 3.11
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.9
|
python-version: '3.11'
|
||||||
- name: Build wheel and source tarball
|
- name: Build wheel and source tarball
|
||||||
run: |
|
run: |
|
||||||
pip install wheel
|
pip install wheel
|
||||||
python setup.py sdist bdist_wheel
|
python setup.py sdist bdist_wheel
|
||||||
- name: Publish a Python distribution to PyPI
|
- 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:
|
with:
|
||||||
user: __token__
|
user: __token__
|
||||||
password: ${{ secrets.pypi_password }}
|
password: ${{ secrets.pypi_password }}
|
||||||
|
|
18
.github/workflows/lint.yml
vendored
18
.github/workflows/lint.yml
vendored
|
@ -1,22 +1,26 @@
|
||||||
name: Lint
|
name: Lint
|
||||||
|
|
||||||
on: [push, pull_request]
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
pull_request:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python 3.9
|
- name: Set up Python 3.11
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: 3.9
|
python-version: '3.11'
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: |
|
run: |
|
||||||
python -m pip install --upgrade pip
|
python -m pip install --upgrade pip
|
||||||
pip install tox
|
pip install tox
|
||||||
- name: Run lint 💅
|
- name: Run pre-commit 💅
|
||||||
run: tox
|
run: tox
|
||||||
env:
|
env:
|
||||||
TOXENV: flake8
|
TOXENV: pre-commit
|
||||||
|
|
33
.github/workflows/tests.yml
vendored
33
.github/workflows/tests.yml
vendored
|
@ -1,6 +1,10 @@
|
||||||
name: Tests
|
name: Tests
|
||||||
|
|
||||||
on: [push, pull_request]
|
on:
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
pull_request:
|
||||||
|
workflow_call:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
|
@ -8,15 +12,29 @@ jobs:
|
||||||
strategy:
|
strategy:
|
||||||
max-parallel: 4
|
max-parallel: 4
|
||||||
matrix:
|
matrix:
|
||||||
django: ["3.2", "4.0", "4.1"]
|
django: ["3.2", "4.2", "5.0", "5.1", "5.2"]
|
||||||
python-version: ["3.8", "3.9", "3.10"]
|
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||||
include:
|
exclude:
|
||||||
- django: "3.2"
|
- django: "3.2"
|
||||||
python-version: "3.7"
|
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"
|
||||||
|
- django: "5.2"
|
||||||
|
python-version: "3.8"
|
||||||
|
- django: "5.2"
|
||||||
|
python-version: "3.9"
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
- uses: actions/checkout@v4
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v2
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
@ -27,4 +45,3 @@ jobs:
|
||||||
run: tox
|
run: tox
|
||||||
env:
|
env:
|
||||||
DJANGO: ${{ matrix.django }}
|
DJANGO: ${{ matrix.django }}
|
||||||
TOXENV: ${{ matrix.toxenv }}
|
|
||||||
|
|
8
.gitignore
vendored
8
.gitignore
vendored
|
@ -11,6 +11,9 @@ __pycache__/
|
||||||
# Distribution / packaging
|
# Distribution / packaging
|
||||||
.Python
|
.Python
|
||||||
env/
|
env/
|
||||||
|
.env/
|
||||||
|
venv/
|
||||||
|
.venv/
|
||||||
build/
|
build/
|
||||||
develop-eggs/
|
develop-eggs/
|
||||||
dist/
|
dist/
|
||||||
|
@ -80,3 +83,8 @@ Session.vim
|
||||||
tags
|
tags
|
||||||
.tox/
|
.tox/
|
||||||
.pytest_cache/
|
.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
|
## 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.
|
This will trigger a series of test and lint checks.
|
||||||
|
|
||||||
|
@ -59,4 +59,4 @@ Then to produce a HTML version of the documentation:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
make html
|
make html
|
||||||
```
|
```
|
||||||
|
|
|
@ -3,4 +3,4 @@ recursive-include graphene_django/templates *
|
||||||
recursive-include graphene_django/static *
|
recursive-include graphene_django/static *
|
||||||
|
|
||||||
include examples/cookbook/cookbook/ingredients/fixtures/ingredients.json
|
include examples/cookbook/cookbook/ingredients/fixtures/ingredients.json
|
||||||
include examples/cookbook-plain/cookbook/ingredients/fixtures/ingredients.json
|
include examples/cookbook-plain/cookbook/ingredients/fixtures/ingredients.json
|
||||||
|
|
7
Makefile
7
Makefile
|
@ -6,18 +6,19 @@ help:
|
||||||
.PHONY: dev-setup ## Install development dependencies
|
.PHONY: dev-setup ## Install development dependencies
|
||||||
dev-setup:
|
dev-setup:
|
||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
|
python -m pre_commit install
|
||||||
|
|
||||||
.PHONY: tests ## Run unit tests
|
.PHONY: tests ## Run unit tests
|
||||||
tests:
|
tests:
|
||||||
py.test graphene_django --cov=graphene_django -vv
|
PYTHONPATH=. pytest graphene_django --cov=graphene_django -vv
|
||||||
|
|
||||||
.PHONY: format ## Format code
|
.PHONY: format ## Format code
|
||||||
format:
|
format:
|
||||||
black --exclude "/migrations/" graphene_django examples setup.py
|
ruff format graphene_django examples setup.py
|
||||||
|
|
||||||
.PHONY: lint ## Lint code
|
.PHONY: lint ## Lint code
|
||||||
lint:
|
lint:
|
||||||
flake8 graphene_django examples
|
ruff graphene_django examples
|
||||||
|
|
||||||
.PHONY: docs ## Generate docs
|
.PHONY: docs ## Generate docs
|
||||||
docs: dev-setup
|
docs: dev-setup
|
||||||
|
|
155
README.md
155
README.md
|
@ -1,8 +1,5 @@
|
||||||
#  Graphene-Django
|
#  Graphene-Django
|
||||||
|
|
||||||
|
|
||||||
A [Django](https://www.djangoproject.com/) integration for [Graphene](http://graphene-python.org/).
|
|
||||||
|
|
||||||
[![build][build-image]][build-url]
|
[![build][build-image]][build-url]
|
||||||
[![pypi][pypi-image]][pypi-url]
|
[![pypi][pypi-image]][pypi-url]
|
||||||
[![Anaconda-Server Badge][conda-image]][conda-url]
|
[![Anaconda-Server Badge][conda-image]][conda-url]
|
||||||
|
@ -17,107 +14,137 @@ A [Django](https://www.djangoproject.com/) integration for [Graphene](http://gra
|
||||||
[conda-image]: https://img.shields.io/conda/vn/conda-forge/graphene-django.svg
|
[conda-image]: https://img.shields.io/conda/vn/conda-forge/graphene-django.svg
|
||||||
[conda-url]: https://anaconda.org/conda-forge/graphene-django
|
[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
|
```sh
|
||||||
pip install "graphene-django>=3"
|
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
|
```python
|
||||||
INSTALLED_APPS = (
|
INSTALLED_APPS = [
|
||||||
# ...
|
# ...
|
||||||
'django.contrib.staticfiles', # Required for GraphiQL
|
|
||||||
'graphene_django',
|
'graphene_django',
|
||||||
)
|
]
|
||||||
|
|
||||||
GRAPHENE = {
|
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
|
```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
|
import graphene
|
||||||
|
from graphene_django import DjangoObjectType
|
||||||
|
from .models import MyModel
|
||||||
|
|
||||||
class User(DjangoObjectType):
|
class MyModelType(DjangoObjectType):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = UserModel
|
model = MyModel
|
||||||
|
|
||||||
class Query(graphene.ObjectType):
|
class Query(graphene.ObjectType):
|
||||||
users = graphene.List(User)
|
mymodels = graphene.List(MyModelType)
|
||||||
|
|
||||||
def resolve_users(self, info):
|
def resolve_mymodels(self, info, **kwargs):
|
||||||
return UserModel.objects.all()
|
return MyModel.objects.all()
|
||||||
|
|
||||||
schema = graphene.Schema(query=Query)
|
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
|
```python
|
||||||
query = '''
|
from django.urls import path
|
||||||
query {
|
from graphene_django.views import GraphQLView
|
||||||
users {
|
from . import schema
|
||||||
name,
|
|
||||||
lastName
|
urlpatterns = [
|
||||||
}
|
# ...
|
||||||
}
|
path('graphql/', GraphQLView.as_view(graphiql=True)), # Given that schema path is defined in GRAPHENE['SCHEMA'] in your settings.py
|
||||||
'''
|
]
|
||||||
result = schema.execute(query)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
To learn more check out the following [examples](examples/):
|
## Testing
|
||||||
|
|
||||||
* **Schema with Filtering**: [Cookbook example](examples/cookbook)
|
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:
|
||||||
* **Relay Schema**: [Starwars Relay example](examples/starwars)
|
|
||||||
|
|
||||||
|
```python
|
||||||
|
from django.test import TestCase
|
||||||
|
from graphene_django.utils.testing import GraphQLTestCase
|
||||||
|
from . import schema
|
||||||
|
|
||||||
## GraphQL testing clients
|
class MyModelAPITestCase(GraphQLTestCase):
|
||||||
- [Firecamp](https://firecamp.io/graphql)
|
GRAPHENE_SCHEMA = schema.schema
|
||||||
- [GraphiQL](https://github.com/graphql/graphiql)
|
|
||||||
|
|
||||||
|
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
|
## 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
|
## Release Notes
|
||||||
|
|
||||||
|
|
122
README.rst
122
README.rst
|
@ -1,122 +0,0 @@
|
||||||
Please read
|
|
||||||
`UPGRADE-v2.0.md <https://github.com/graphql-python/graphene/blob/master/UPGRADE-v2.0.md>`__
|
|
||||||
to learn how to upgrade to Graphene ``2.0``.
|
|
||||||
|
|
||||||
--------------
|
|
||||||
|
|
||||||
|Graphene Logo| Graphene-Django |Build Status| |PyPI version| |Coverage Status|
|
|
||||||
===============================================================================
|
|
||||||
|
|
||||||
A `Django <https://www.djangoproject.com/>`__ integration for
|
|
||||||
`Graphene <http://graphene-python.org/>`__.
|
|
||||||
|
|
||||||
|
|
||||||
Documentation
|
|
||||||
-------------
|
|
||||||
|
|
||||||
`Visit the documentation to get started! <https://docs.graphene-python.org/projects/django/en/latest/>`__
|
|
||||||
|
|
||||||
Quickstart
|
|
||||||
----------
|
|
||||||
|
|
||||||
For installing graphene, just run this command in your shell
|
|
||||||
|
|
||||||
.. code:: bash
|
|
||||||
|
|
||||||
pip install "graphene-django>=3"
|
|
||||||
|
|
||||||
Settings
|
|
||||||
~~~~~~~~
|
|
||||||
|
|
||||||
.. code:: python
|
|
||||||
|
|
||||||
INSTALLED_APPS = (
|
|
||||||
# ...
|
|
||||||
'graphene_django',
|
|
||||||
)
|
|
||||||
|
|
||||||
GRAPHENE = {
|
|
||||||
'SCHEMA': 'app.schema.schema' # Where your Graphene schema lives
|
|
||||||
}
|
|
||||||
|
|
||||||
Urls
|
|
||||||
~~~~
|
|
||||||
|
|
||||||
We need to set up a ``GraphQL`` endpoint in our Django app, so we can
|
|
||||||
serve the queries.
|
|
||||||
|
|
||||||
.. code:: python
|
|
||||||
|
|
||||||
from django.conf.urls import url
|
|
||||||
from graphene_django.views import GraphQLView
|
|
||||||
|
|
||||||
urlpatterns = [
|
|
||||||
# ...
|
|
||||||
url(r'^graphql$', GraphQLView.as_view(graphiql=True)),
|
|
||||||
]
|
|
||||||
|
|
||||||
Examples
|
|
||||||
--------
|
|
||||||
|
|
||||||
Here is a simple Django model:
|
|
||||||
|
|
||||||
.. code:: python
|
|
||||||
|
|
||||||
from django.db import models
|
|
||||||
|
|
||||||
class UserModel(models.Model):
|
|
||||||
name = models.CharField(max_length=100)
|
|
||||||
last_name = models.CharField(max_length=100)
|
|
||||||
|
|
||||||
To create a GraphQL schema for it you simply have to write the
|
|
||||||
following:
|
|
||||||
|
|
||||||
.. code:: python
|
|
||||||
|
|
||||||
from graphene_django import DjangoObjectType
|
|
||||||
import graphene
|
|
||||||
|
|
||||||
class User(DjangoObjectType):
|
|
||||||
class Meta:
|
|
||||||
model = UserModel
|
|
||||||
|
|
||||||
class Query(graphene.ObjectType):
|
|
||||||
users = graphene.List(User)
|
|
||||||
|
|
||||||
@graphene.resolve_only_args
|
|
||||||
def resolve_users(self):
|
|
||||||
return UserModel.objects.all()
|
|
||||||
|
|
||||||
schema = graphene.Schema(query=Query)
|
|
||||||
|
|
||||||
Then you can simply query the schema:
|
|
||||||
|
|
||||||
.. code:: python
|
|
||||||
|
|
||||||
query = '''
|
|
||||||
query {
|
|
||||||
users {
|
|
||||||
name,
|
|
||||||
lastName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
'''
|
|
||||||
result = schema.execute(query)
|
|
||||||
|
|
||||||
To learn more check out the following `examples <examples/>`__:
|
|
||||||
|
|
||||||
- **Schema with Filtering**: `Cookbook example <examples/cookbook>`__
|
|
||||||
- **Relay Schema**: `Starwars Relay example <examples/starwars>`__
|
|
||||||
|
|
||||||
Contributing
|
|
||||||
------------
|
|
||||||
|
|
||||||
See `CONTRIBUTING.md <CONTRIBUTING.md>`__.
|
|
||||||
|
|
||||||
.. |Graphene Logo| image:: http://graphene-python.org/favicon.png
|
|
||||||
.. |Build Status| image:: https://github.com/graphql-python/graphene-django/workflows/Tests/badge.svg
|
|
||||||
:target: https://github.com/graphql-python/graphene-django/actions
|
|
||||||
.. |PyPI version| image:: https://badge.fury.io/py/graphene-django.svg
|
|
||||||
:target: https://badge.fury.io/py/graphene-django
|
|
||||||
.. |Coverage Status| image:: https://coveralls.io/repos/graphql-python/graphene-django/badge.svg?branch=master&service=github
|
|
||||||
:target: https://coveralls.io/github/graphql-python/graphene-django?branch=master
|
|
|
@ -144,6 +144,21 @@ If you are using ``DjangoObjectType`` you can define a custom `get_queryset`.
|
||||||
return queryset.filter(published=True)
|
return queryset.filter(published=True)
|
||||||
return queryset
|
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
|
Filtering ID-based Node Access
|
||||||
------------------------------
|
------------------------------
|
||||||
|
@ -197,8 +212,8 @@ For Django 2.2 and above:
|
||||||
.. code:: python
|
.. code:: python
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
# some other urls
|
# some other urls
|
||||||
path('graphql/', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
|
path('graphql/', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
|
||||||
]
|
]
|
||||||
|
|
||||||
.. _LoginRequiredMixin: https://docs.djangoproject.com/en/dev/topics/auth/default/#the-loginrequired-mixin
|
.. _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.
|
# This is also used if you do content translation via gettext catalogs.
|
||||||
# Usually you set "language" from the command line for these cases.
|
# 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
|
# There are two options for replacing |today|: either, you set today to some
|
||||||
# non-false value, then it is used:
|
# 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.
|
# 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),
|
||||||
|
}
|
||||||
|
|
|
@ -2,8 +2,8 @@ Filtering
|
||||||
=========
|
=========
|
||||||
|
|
||||||
Graphene integrates with
|
Graphene integrates with
|
||||||
`django-filter <https://django-filter.readthedocs.io/en/main/>`__ to provide filtering of results.
|
`django-filter <https://django-filter.readthedocs.io/en/stable/>`__ to provide filtering of results.
|
||||||
See the `usage documentation <https://django-filter.readthedocs.io/en/main/guide/usage.html#the-filter>`__
|
See the `usage documentation <https://django-filter.readthedocs.io/en/stable/guide/usage.html#the-filter>`__
|
||||||
for details on the format for ``filter_fields``.
|
for details on the format for ``filter_fields``.
|
||||||
|
|
||||||
This filtering is automatically available when implementing a ``relay.Node``.
|
This filtering is automatically available when implementing a ``relay.Node``.
|
||||||
|
|
|
@ -33,5 +33,6 @@ For more advanced use, check out the Relay tutorial.
|
||||||
authorization
|
authorization
|
||||||
debug
|
debug
|
||||||
introspection
|
introspection
|
||||||
|
validation
|
||||||
testing
|
testing
|
||||||
settings
|
settings
|
||||||
|
|
|
@ -57,9 +57,9 @@ specify the parameters in your settings.py:
|
||||||
.. code:: python
|
.. code:: python
|
||||||
|
|
||||||
GRAPHENE = {
|
GRAPHENE = {
|
||||||
'SCHEMA': 'tutorial.quickstart.schema',
|
'SCHEMA': 'tutorial.quickstart.schema',
|
||||||
'SCHEMA_OUTPUT': 'data/schema.json', # defaults to schema.json,
|
'SCHEMA_OUTPUT': 'data/schema.json', # defaults to schema.json,
|
||||||
'SCHEMA_INDENT': 2, # Defaults to None (displays all data on a single line)
|
'SCHEMA_INDENT': 2, # Defaults to None (displays all data on a single line)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -125,6 +125,55 @@ to change how the form is saved or to return a different Graphene object type.
|
||||||
If the form is *not* valid then a list of errors will be returned. These errors have two fields: ``field``, a string
|
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.
|
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
|
Django REST Framework
|
||||||
---------------------
|
---------------------
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
Sphinx==1.5.3
|
Sphinx==7.0.0
|
||||||
sphinx-autobuild==0.7.1
|
sphinx-autobuild==2021.3.14
|
||||||
|
pygments-graphql-lexer==0.1.0
|
||||||
# Docs template
|
# Docs template
|
||||||
http://graphene-python.org/sphinx_graphene_theme.zip
|
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 CategoryType(DjangoObjectType):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Category
|
model = Category
|
||||||
fields = '__all__'
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
class IngredientType(DjangoObjectType):
|
class IngredientType(DjangoObjectType):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Ingredient
|
model = Ingredient
|
||||||
fields = '__all__'
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
class Query(object):
|
class Query:
|
||||||
category = graphene.Field(CategoryType,
|
category = graphene.Field(CategoryType, id=graphene.Int(), name=graphene.String())
|
||||||
id=graphene.Int(),
|
all_categories = graphene.List(CategoryType)
|
||||||
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,
|
def resolve_all_categories(self, info, **kwargs):
|
||||||
id=graphene.Int(),
|
return Category.objects.all()
|
||||||
name=graphene.String())
|
|
||||||
all_ingredients = graphene.List(IngredientType)
|
|
||||||
|
|
||||||
def resolve_all_categories(self, info, **kwargs):
|
def resolve_all_ingredients(self, info, **kwargs):
|
||||||
return Category.objects.all()
|
return Ingredient.objects.all()
|
||||||
|
|
||||||
def resolve_all_ingredients(self, info, **kwargs):
|
def resolve_category(self, info, **kwargs):
|
||||||
return Ingredient.objects.all()
|
id = kwargs.get("id")
|
||||||
|
name = kwargs.get("name")
|
||||||
|
|
||||||
def resolve_category(self, info, **kwargs):
|
if id is not None:
|
||||||
id = kwargs.get('id')
|
return Category.objects.get(pk=id)
|
||||||
name = kwargs.get('name')
|
|
||||||
|
|
||||||
if id is not None:
|
if name is not None:
|
||||||
return Category.objects.get(pk=id)
|
return Category.objects.get(name=name)
|
||||||
|
|
||||||
if name is not None:
|
return None
|
||||||
return Category.objects.get(name=name)
|
|
||||||
|
|
||||||
return None
|
def resolve_ingredient(self, info, **kwargs):
|
||||||
|
id = kwargs.get("id")
|
||||||
|
name = kwargs.get("name")
|
||||||
|
|
||||||
def resolve_ingredient(self, info, **kwargs):
|
if id is not None:
|
||||||
id = kwargs.get('id')
|
return Ingredient.objects.get(pk=id)
|
||||||
name = kwargs.get('name')
|
|
||||||
|
|
||||||
if id is not None:
|
if name is not None:
|
||||||
return Ingredient.objects.get(pk=id)
|
return Ingredient.objects.get(name=name)
|
||||||
|
|
||||||
if name is not None:
|
return None
|
||||||
return Ingredient.objects.get(name=name)
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
|
@ -6,7 +6,7 @@ Graphene-Django can be customised using settings. This page explains each settin
|
||||||
Usage
|
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
|
.. code:: python
|
||||||
|
|
||||||
|
@ -142,6 +142,15 @@ Default: ``False``
|
||||||
# ]
|
# ]
|
||||||
|
|
||||||
|
|
||||||
|
``DJANGO_CHOICE_FIELD_ENUM_CONVERT``
|
||||||
|
--------------------------------------
|
||||||
|
|
||||||
|
When set to ``True`` Django choice fields are automatically converted into Enum types.
|
||||||
|
|
||||||
|
Can be disabled globally by setting it to ``False``.
|
||||||
|
|
||||||
|
Default: ``True``
|
||||||
|
|
||||||
``DJANGO_CHOICE_FIELD_ENUM_V2_NAMING``
|
``DJANGO_CHOICE_FIELD_ENUM_V2_NAMING``
|
||||||
--------------------------------------
|
--------------------------------------
|
||||||
|
|
||||||
|
@ -197,9 +206,6 @@ Set to ``False`` if you want to disable GraphiQL headers editor tab for some rea
|
||||||
|
|
||||||
This setting is passed to ``headerEditorEnabled`` GraphiQL options, for details refer to GraphiQLDocs_.
|
This setting is passed to ``headerEditorEnabled`` GraphiQL options, for details refer to GraphiQLDocs_.
|
||||||
|
|
||||||
.. _GraphiQLDocs: https://github.com/graphql/graphiql/tree/main/packages/graphiql#options
|
|
||||||
|
|
||||||
|
|
||||||
Default: ``True``
|
Default: ``True``
|
||||||
|
|
||||||
.. code:: python
|
.. code:: python
|
||||||
|
@ -224,14 +230,12 @@ Default: ``/graphql``
|
||||||
|
|
||||||
|
|
||||||
``GRAPHIQL_SHOULD_PERSIST_HEADERS``
|
``GRAPHIQL_SHOULD_PERSIST_HEADERS``
|
||||||
---------------------
|
-----------------------------------
|
||||||
|
|
||||||
Set to ``True`` if you want to persist GraphiQL headers after refreshing the page.
|
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_.
|
This setting is passed to ``shouldPersistHeaders`` GraphiQL options, for details refer to GraphiQLDocs_.
|
||||||
|
|
||||||
.. _GraphiQLDocs: https://github.com/graphql/graphiql/tree/main/packages/graphiql#options
|
|
||||||
|
|
||||||
|
|
||||||
Default: ``False``
|
Default: ``False``
|
||||||
|
|
||||||
|
@ -240,3 +244,48 @@ Default: ``False``
|
||||||
GRAPHENE = {
|
GRAPHENE = {
|
||||||
'GRAPHIQL_SHOULD_PERSIST_HEADERS': False,
|
'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``
|
||||||
|
|
|
@ -85,8 +85,8 @@ Make sure the app name in ``cookbook.ingredients.apps.IngredientsConfig`` is set
|
||||||
# cookbook/ingredients/apps.py
|
# cookbook/ingredients/apps.py
|
||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class IngredientsConfig(AppConfig):
|
class IngredientsConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
name = 'cookbook.ingredients'
|
name = 'cookbook.ingredients'
|
||||||
|
@ -104,7 +104,7 @@ Load some test data
|
||||||
|
|
||||||
Now is a good time to load up some test data. The easiest option will be
|
Now is a good time to load up some test data. The easiest option will be
|
||||||
to `download the
|
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
|
fixture and place it in
|
||||||
``cookbook/ingredients/fixtures/ingredients.json``. You can then run the
|
``cookbook/ingredients/fixtures/ingredients.json``. You can then run the
|
||||||
following:
|
following:
|
||||||
|
|
|
@ -7,12 +7,12 @@ Graphene has a number of additional features that are designed to make
|
||||||
working with Django *really simple*.
|
working with Django *really simple*.
|
||||||
|
|
||||||
Note: The code in this quickstart is pulled from the `cookbook example
|
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:
|
A good idea is to check the following things first:
|
||||||
|
|
||||||
* `Graphene Relay documentation <http://docs.graphene-python.org/en/latest/relay/>`__
|
* `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
|
Setup the Django project
|
||||||
------------------------
|
------------------------
|
||||||
|
@ -87,7 +87,7 @@ Load some test data
|
||||||
|
|
||||||
Now is a good time to load up some test data. The easiest option will be
|
Now is a good time to load up some test data. The easiest option will be
|
||||||
to `download the
|
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
|
fixture and place it in
|
||||||
``cookbook/ingredients/fixtures/ingredients.json``. You can then run the
|
``cookbook/ingredients/fixtures/ingredients.json``. You can then run the
|
||||||
following:
|
following:
|
||||||
|
|
29
docs/validation.rst
Normal file
29
docs/validation.rst
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
Query Validation
|
||||||
|
================
|
||||||
|
|
||||||
|
Graphene-Django supports query validation by allowing passing a list of validation rules (subclasses of `ValidationRule <https://github.com/graphql-python/graphql-core/blob/v3.2.3/src/graphql/validation/rules/__init__.py>`_ from graphql-core) to the ``validation_rules`` option in ``GraphQLView``.
|
||||||
|
|
||||||
|
.. code:: python
|
||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
from graphene.validation import DisableIntrospection
|
||||||
|
from graphene_django.views import GraphQLView
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("graphql", GraphQLView.as_view(validation_rules=(DisableIntrospection,))),
|
||||||
|
]
|
||||||
|
|
||||||
|
or
|
||||||
|
|
||||||
|
.. code:: python
|
||||||
|
|
||||||
|
from django.urls import path
|
||||||
|
from graphene.validation import DisableIntrospection
|
||||||
|
from graphene_django.views import GraphQLView
|
||||||
|
|
||||||
|
class View(GraphQLView):
|
||||||
|
validation_rules = (DisableIntrospection,)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("graphql", View.as_view()),
|
||||||
|
]
|
|
@ -14,7 +14,7 @@ whole Graphene repository:
|
||||||
```bash
|
```bash
|
||||||
# Get the example project code
|
# Get the example project code
|
||||||
git clone https://github.com/graphql-python/graphene-django.git
|
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
|
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!
|
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-plain/#testing-our-graphql-schema)
|
||||||
for some example queries)
|
for some example queries)
|
||||||
|
|
||||||
|
Testing local graphene-django changes
|
||||||
|
-------------------------------------
|
||||||
|
|
||||||
|
In `requirements.txt`, replace the entire `graphene-django=...` line with the following (so that we install the local version instead of the one from PyPI):
|
||||||
|
|
||||||
|
```
|
||||||
|
../../ # graphene-django
|
||||||
|
```
|
||||||
|
|
|
@ -1 +1,52 @@
|
||||||
[{"model": "ingredients.category", "pk": 1, "fields": {"name": "Dairy"}}, {"model": "ingredients.category", "pk": 2, "fields": {"name": "Meat"}}, {"model": "ingredients.ingredient", "pk": 1, "fields": {"name": "Eggs", "notes": "Good old eggs", "category": 1}}, {"model": "ingredients.ingredient", "pk": 2, "fields": {"name": "Milk", "notes": "Comes from a cow", "category": 1}}, {"model": "ingredients.ingredient", "pk": 3, "fields": {"name": "Beef", "notes": "Much like milk, this comes from a cow", "category": 2}}, {"model": "ingredients.ingredient", "pk": 4, "fields": {"name": "Chicken", "notes": "Definitely doesn't come from a cow", "category": 2}}]
|
[
|
||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"name": "Dairy"
|
||||||
|
},
|
||||||
|
"model": "ingredients.category",
|
||||||
|
"pk": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"name": "Meat"
|
||||||
|
},
|
||||||
|
"model": "ingredients.category",
|
||||||
|
"pk": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"category": 1,
|
||||||
|
"name": "Eggs",
|
||||||
|
"notes": "Good old eggs"
|
||||||
|
},
|
||||||
|
"model": "ingredients.ingredient",
|
||||||
|
"pk": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"category": 1,
|
||||||
|
"name": "Milk",
|
||||||
|
"notes": "Comes from a cow"
|
||||||
|
},
|
||||||
|
"model": "ingredients.ingredient",
|
||||||
|
"pk": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"category": 2,
|
||||||
|
"name": "Beef",
|
||||||
|
"notes": "Much like milk, this comes from a cow"
|
||||||
|
},
|
||||||
|
"model": "ingredients.ingredient",
|
||||||
|
"pk": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"category": 2,
|
||||||
|
"name": "Chicken",
|
||||||
|
"notes": "Definitely doesn't come from a cow"
|
||||||
|
},
|
||||||
|
"model": "ingredients.ingredient",
|
||||||
|
"pk": 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
|
@ -1,33 +1,52 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Generated by Django 1.9 on 2015-12-04 18:15
|
# Generated by Django 1.9 on 2015-12-04 18:15
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = []
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Category',
|
name="Category",
|
||||||
fields=[
|
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(
|
migrations.CreateModel(
|
||||||
name='Ingredient',
|
name="Ingredient",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('name', models.CharField(max_length=100)),
|
"id",
|
||||||
('notes', models.TextField()),
|
models.AutoField(
|
||||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredients', to='ingredients.Category')),
|
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
|
# Generated by Django 1.9 on 2016-11-04 00:50
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('ingredients', '0001_initial'),
|
("ingredients", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='ingredient',
|
model_name="ingredient",
|
||||||
name='notes',
|
name="notes",
|
||||||
field=models.TextField(blank=True, null=True),
|
field=models.TextField(blank=True, null=True),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -4,14 +4,13 @@ from django.db import migrations
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('ingredients', '0002_auto_20161104_0050'),
|
("ingredients", "0002_auto_20161104_0050"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterModelOptions(
|
migrations.AlterModelOptions(
|
||||||
name='category',
|
name="category",
|
||||||
options={'verbose_name_plural': 'Categories'},
|
options={"verbose_name_plural": "Categories"},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -16,7 +16,7 @@ class IngredientType(DjangoObjectType):
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
class Query(object):
|
class Query:
|
||||||
category = graphene.Field(CategoryType, id=graphene.Int(), name=graphene.String())
|
category = graphene.Field(CategoryType, id=graphene.Int(), name=graphene.String())
|
||||||
all_categories = graphene.List(CategoryType)
|
all_categories = graphene.List(CategoryType)
|
||||||
|
|
||||||
|
|
|
@ -1,36 +1,69 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Generated by Django 1.9 on 2015-12-04 18:20
|
# Generated by Django 1.9 on 2015-12-04 18:20
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('ingredients', '0001_initial'),
|
("ingredients", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Recipe',
|
name="Recipe",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('title', models.CharField(max_length=100)),
|
"id",
|
||||||
('instructions', models.TextField()),
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("title", models.CharField(max_length=100)),
|
||||||
|
("instructions", models.TextField()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='RecipeIngredient',
|
name="RecipeIngredient",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('amount', models.FloatField()),
|
"id",
|
||||||
('unit', models.CharField(choices=[('kg', 'Kilograms'), ('l', 'Litres'), ('', 'Units')], max_length=20)),
|
models.AutoField(
|
||||||
('ingredient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='used_by', to='ingredients.Ingredient')),
|
auto_created=True,
|
||||||
('recipes', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='amounts', to='recipes.Recipe')),
|
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
|
# Generated by Django 1.9 on 2016-11-04 01:06
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('recipes', '0001_initial'),
|
("recipes", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.RenameField(
|
migrations.RenameField(
|
||||||
model_name='recipeingredient',
|
model_name="recipeingredient",
|
||||||
old_name='recipes',
|
old_name="recipes",
|
||||||
new_name='recipe',
|
new_name="recipe",
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='recipeingredient',
|
model_name="recipeingredient",
|
||||||
name='unit',
|
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),
|
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):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('recipes', '0002_auto_20161104_0106'),
|
("recipes", "0002_auto_20161104_0106"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='recipeingredient',
|
model_name="recipeingredient",
|
||||||
name='unit',
|
name="unit",
|
||||||
field=models.CharField(choices=[('unit', 'Units'), ('kg', 'Kilograms'), ('l', 'Litres'), ('st', 'Shots')], max_length=20),
|
field=models.CharField(
|
||||||
|
choices=[
|
||||||
|
("unit", "Units"),
|
||||||
|
("kg", "Kilograms"),
|
||||||
|
("l", "Litres"),
|
||||||
|
("st", "Shots"),
|
||||||
|
],
|
||||||
|
max_length=20,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -16,7 +16,7 @@ class RecipeIngredientType(DjangoObjectType):
|
||||||
fields = "__all__"
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
class Query(object):
|
class Query:
|
||||||
recipe = graphene.Field(RecipeType, id=graphene.Int(), title=graphene.String())
|
recipe = graphene.Field(RecipeType, id=graphene.Int(), title=graphene.String())
|
||||||
all_recipes = graphene.List(RecipeType)
|
all_recipes = graphene.List(RecipeType)
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
|
import graphene
|
||||||
|
from graphene_django.debug import DjangoDebug
|
||||||
|
|
||||||
import cookbook.ingredients.schema
|
import cookbook.ingredients.schema
|
||||||
import cookbook.recipes.schema
|
import cookbook.recipes.schema
|
||||||
import graphene
|
|
||||||
|
|
||||||
from graphene_django.debug import DjangoDebug
|
|
||||||
|
|
||||||
|
|
||||||
class Query(
|
class Query(
|
||||||
|
|
|
@ -5,10 +5,10 @@ Django settings for cookbook project.
|
||||||
Generated by 'django-admin startproject' using Django 1.9.
|
Generated by 'django-admin startproject' using Django 1.9.
|
||||||
|
|
||||||
For more information on this file, see
|
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
|
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
|
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
|
# 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!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY = "_$=$%eqxk$8ss4n7mtgarw^5$8^d5+c83!vwatr@i_81myb=e4"
|
SECRET_KEY = "_$=$%eqxk$8ss4n7mtgarw^5$8^d5+c83!vwatr@i_81myb=e4"
|
||||||
|
@ -81,7 +81,7 @@ WSGI_APPLICATION = "cookbook.wsgi.application"
|
||||||
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
# https://docs.djangoproject.com/en/1.9/ref/settings/#databases
|
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
"default": {
|
"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
|
# 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 = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
|
@ -105,7 +107,7 @@ AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
|
||||||
|
|
||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/1.9/topics/i18n/
|
# https://docs.djangoproject.com/en/3.2/topics/i18n/
|
||||||
|
|
||||||
LANGUAGE_CODE = "en-us"
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
|
@ -119,6 +121,6 @@ USE_TZ = True
|
||||||
|
|
||||||
|
|
||||||
# Static files (CSS, JavaScript, Images)
|
# 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/"
|
STATIC_URL = "/static/"
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
from django.urls import path
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.urls import path
|
||||||
|
|
||||||
from graphene_django.views import GraphQLView
|
from graphene_django.views import GraphQLView
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
path("graphql/", GraphQLView.as_view(graphiql=True)),
|
path("graphql/", GraphQLView.as_view(graphiql=True)),
|
||||||
|
|
|
@ -1,4 +1,3 @@
|
||||||
graphene>=2.1,<3
|
django~=3.2
|
||||||
graphene-django>=2.1,<3
|
graphene
|
||||||
graphql-core>=2.1,<3
|
graphene-django>=3.1
|
||||||
django==3.1.14
|
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
Cookbook Example Django Project
|
Cookbook Example (Relay) Django Project
|
||||||
===============================
|
===============================
|
||||||
|
|
||||||
This example project demos integration between Graphene and Django.
|
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
|
Now head on over to
|
||||||
[http://127.0.0.1:8000/graphql](http://127.0.0.1:8000/graphql)
|
[http://127.0.0.1:8000/graphql](http://127.0.0.1:8000/graphql)
|
||||||
and run some queries!
|
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)
|
for some example queries)
|
||||||
|
|
|
@ -1 +1,52 @@
|
||||||
[{"model": "ingredients.category", "pk": 1, "fields": {"name": "Dairy"}}, {"model": "ingredients.category", "pk": 2, "fields": {"name": "Meat"}}, {"model": "ingredients.ingredient", "pk": 1, "fields": {"name": "Eggs", "notes": "Good old eggs", "category": 1}}, {"model": "ingredients.ingredient", "pk": 2, "fields": {"name": "Milk", "notes": "Comes from a cow", "category": 1}}, {"model": "ingredients.ingredient", "pk": 3, "fields": {"name": "Beef", "notes": "Much like milk, this comes from a cow", "category": 2}}, {"model": "ingredients.ingredient", "pk": 4, "fields": {"name": "Chicken", "notes": "Definitely doesn't come from a cow", "category": 2}}]
|
[
|
||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"name": "Dairy"
|
||||||
|
},
|
||||||
|
"model": "ingredients.category",
|
||||||
|
"pk": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"name": "Meat"
|
||||||
|
},
|
||||||
|
"model": "ingredients.category",
|
||||||
|
"pk": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"category": 1,
|
||||||
|
"name": "Eggs",
|
||||||
|
"notes": "Good old eggs"
|
||||||
|
},
|
||||||
|
"model": "ingredients.ingredient",
|
||||||
|
"pk": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"category": 1,
|
||||||
|
"name": "Milk",
|
||||||
|
"notes": "Comes from a cow"
|
||||||
|
},
|
||||||
|
"model": "ingredients.ingredient",
|
||||||
|
"pk": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"category": 2,
|
||||||
|
"name": "Beef",
|
||||||
|
"notes": "Much like milk, this comes from a cow"
|
||||||
|
},
|
||||||
|
"model": "ingredients.ingredient",
|
||||||
|
"pk": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"fields": {
|
||||||
|
"category": 2,
|
||||||
|
"name": "Chicken",
|
||||||
|
"notes": "Definitely doesn't come from a cow"
|
||||||
|
},
|
||||||
|
"model": "ingredients.ingredient",
|
||||||
|
"pk": 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
|
@ -1,33 +1,52 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Generated by Django 1.9 on 2015-12-04 18:15
|
# Generated by Django 1.9 on 2015-12-04 18:15
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = []
|
||||||
]
|
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Category',
|
name="Category",
|
||||||
fields=[
|
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(
|
migrations.CreateModel(
|
||||||
name='Ingredient',
|
name="Ingredient",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('name', models.CharField(max_length=100)),
|
"id",
|
||||||
('notes', models.TextField()),
|
models.AutoField(
|
||||||
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredients', to='ingredients.Category')),
|
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
|
# Generated by Django 1.9 on 2016-11-04 00:50
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('ingredients', '0001_initial'),
|
("ingredients", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='ingredient',
|
model_name="ingredient",
|
||||||
name='notes',
|
name="notes",
|
||||||
field=models.TextField(blank=True, null=True),
|
field=models.TextField(blank=True, null=True),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
from cookbook.ingredients.models import Category, Ingredient
|
|
||||||
from graphene import Node
|
from graphene import Node
|
||||||
from graphene_django.filter import DjangoFilterConnectionField
|
from graphene_django.filter import DjangoFilterConnectionField
|
||||||
from graphene_django.types import DjangoObjectType
|
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.
|
# 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)
|
# 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)
|
category = Node.Field(CategoryNode)
|
||||||
all_categories = DjangoFilterConnectionField(CategoryNode)
|
all_categories = DjangoFilterConnectionField(CategoryNode)
|
||||||
|
|
||||||
|
|
|
@ -1,36 +1,69 @@
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
# Generated by Django 1.9 on 2015-12-04 18:20
|
# Generated by Django 1.9 on 2015-12-04 18:20
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
import django.db.models.deletion
|
import django.db.models.deletion
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
initial = True
|
initial = True
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('ingredients', '0001_initial'),
|
("ingredients", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Recipe',
|
name="Recipe",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('title', models.CharField(max_length=100)),
|
"id",
|
||||||
('instructions', models.TextField()),
|
models.AutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("title", models.CharField(max_length=100)),
|
||||||
|
("instructions", models.TextField()),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='RecipeIngredient',
|
name="RecipeIngredient",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('amount', models.FloatField()),
|
"id",
|
||||||
('unit', models.CharField(choices=[('kg', 'Kilograms'), ('l', 'Litres'), ('', 'Units')], max_length=20)),
|
models.AutoField(
|
||||||
('ingredient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='used_by', to='ingredients.Ingredient')),
|
auto_created=True,
|
||||||
('recipes', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='amounts', to='recipes.Recipe')),
|
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
|
# Generated by Django 1.9 on 2016-11-04 01:06
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('recipes', '0001_initial'),
|
("recipes", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.RenameField(
|
migrations.RenameField(
|
||||||
model_name='recipeingredient',
|
model_name="recipeingredient",
|
||||||
old_name='recipes',
|
old_name="recipes",
|
||||||
new_name='recipe',
|
new_name="recipe",
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='recipeingredient',
|
model_name="recipeingredient",
|
||||||
name='unit',
|
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),
|
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):
|
class Recipe(models.Model):
|
||||||
title = models.CharField(max_length=100)
|
title = models.CharField(max_length=100)
|
||||||
instructions = models.TextField()
|
instructions = models.TextField()
|
||||||
__unicode__ = lambda self: self.title
|
|
||||||
|
def __unicode__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
|
||||||
class RecipeIngredient(models.Model):
|
class RecipeIngredient(models.Model):
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
from cookbook.recipes.models import Recipe, RecipeIngredient
|
|
||||||
from graphene import Node
|
from graphene import Node
|
||||||
from graphene_django.filter import DjangoFilterConnectionField
|
from graphene_django.filter import DjangoFilterConnectionField
|
||||||
from graphene_django.types import DjangoObjectType
|
from graphene_django.types import DjangoObjectType
|
||||||
|
|
||||||
|
from cookbook.recipes.models import Recipe, RecipeIngredient
|
||||||
|
|
||||||
|
|
||||||
class RecipeNode(DjangoObjectType):
|
class RecipeNode(DjangoObjectType):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -25,7 +26,7 @@ class RecipeIngredientNode(DjangoObjectType):
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class Query(object):
|
class Query:
|
||||||
recipe = Node.Field(RecipeNode)
|
recipe = Node.Field(RecipeNode)
|
||||||
all_recipes = DjangoFilterConnectionField(RecipeNode)
|
all_recipes = DjangoFilterConnectionField(RecipeNode)
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
|
import graphene
|
||||||
|
from graphene_django.debug import DjangoDebug
|
||||||
|
|
||||||
import cookbook.ingredients.schema
|
import cookbook.ingredients.schema
|
||||||
import cookbook.recipes.schema
|
import cookbook.recipes.schema
|
||||||
import graphene
|
|
||||||
|
|
||||||
from graphene_django.debug import DjangoDebug
|
|
||||||
|
|
||||||
|
|
||||||
class Query(
|
class Query(
|
||||||
|
|
|
@ -3,7 +3,6 @@ from django.contrib import admin
|
||||||
|
|
||||||
from graphene_django.views import GraphQLView
|
from graphene_django.views import GraphQLView
|
||||||
|
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
url(r"^admin/", admin.site.urls),
|
url(r"^admin/", admin.site.urls),
|
||||||
url(r"^graphql$", GraphQLView.as_view(graphiql=True)),
|
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>=2.1,<3
|
||||||
graphene-django>=2.1,<3
|
graphene-django>=2.1,<3
|
||||||
graphql-core>=2.1,<3
|
graphql-core>=2.1,<3
|
||||||
django==3.1.14
|
django==4.2.18
|
||||||
django-filter>=2
|
django-filter>=2
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import sys
|
|
||||||
import os
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
ROOT_PATH = os.path.dirname(os.path.abspath(__file__))
|
ROOT_PATH = os.path.dirname(os.path.abspath(__file__))
|
||||||
sys.path.insert(0, ROOT_PATH + "/examples/")
|
sys.path.insert(0, ROOT_PATH + "/examples/")
|
||||||
|
@ -28,3 +28,5 @@ TEMPLATES = [
|
||||||
GRAPHENE = {"SCHEMA": "graphene_django.tests.schema_view.schema"}
|
GRAPHENE = {"SCHEMA": "graphene_django.tests.schema_view.schema"}
|
||||||
|
|
||||||
ROOT_URLCONF = "graphene_django.tests.urls"
|
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,
|
# 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.
|
# 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()
|
falcon.save()
|
||||||
|
|
||||||
homeOne = Ship(id="5", name="Home One", faction=rebels)
|
homeOne = Ship(id="5", name="Home One", faction=rebels)
|
||||||
|
|
|
@ -1,5 +1,3 @@
|
||||||
from __future__ import absolute_import
|
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
import graphene
|
import graphene
|
||||||
from graphene import Schema, relay, resolve_only_args
|
from graphene import Schema, relay
|
||||||
from graphene_django import DjangoConnectionField, DjangoObjectType
|
from graphene_django import DjangoConnectionField, DjangoObjectType
|
||||||
|
|
||||||
from .data import create_ship, get_empire, get_faction, get_rebels, get_ship, get_ships
|
from .data import create_ship, get_empire, get_faction, get_rebels, get_ship, get_ships
|
||||||
from .models import Character as CharacterModel
|
from .models import (
|
||||||
from .models import Faction as FactionModel
|
Character as CharacterModel,
|
||||||
from .models import Ship as ShipModel
|
Faction as FactionModel,
|
||||||
|
Ship as ShipModel,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class Ship(DjangoObjectType):
|
class Ship(DjangoObjectType):
|
||||||
|
@ -60,16 +62,13 @@ class Query(graphene.ObjectType):
|
||||||
node = relay.Node.Field()
|
node = relay.Node.Field()
|
||||||
ships = DjangoConnectionField(Ship, description="All the ships.")
|
ships = DjangoConnectionField(Ship, description="All the ships.")
|
||||||
|
|
||||||
@resolve_only_args
|
def resolve_ships(self, info):
|
||||||
def resolve_ships(self):
|
|
||||||
return get_ships()
|
return get_ships()
|
||||||
|
|
||||||
@resolve_only_args
|
def resolve_rebels(self, info):
|
||||||
def resolve_rebels(self):
|
|
||||||
return get_rebels()
|
return get_rebels()
|
||||||
|
|
||||||
@resolve_only_args
|
def resolve_empire(self, info):
|
||||||
def resolve_empire(self):
|
|
||||||
return get_empire()
|
return get_empire()
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -40,7 +40,7 @@ def test_mutations():
|
||||||
{"node": {"id": "U2hpcDox", "name": "X-Wing"}},
|
{"node": {"id": "U2hpcDox", "name": "X-Wing"}},
|
||||||
{"node": {"id": "U2hpcDoy", "name": "Y-Wing"}},
|
{"node": {"id": "U2hpcDoy", "name": "Y-Wing"}},
|
||||||
{"node": {"id": "U2hpcDoz", "name": "A-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": "U2hpcDo1", "name": "Home One"}},
|
||||||
{"node": {"id": "U2hpcDo5", "name": "Peter"}},
|
{"node": {"id": "U2hpcDo5", "name": "Peter"}},
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
from .fields import DjangoConnectionField, DjangoListField
|
from .fields import DjangoConnectionField, DjangoListField
|
||||||
from .types import DjangoObjectType
|
from .types import DjangoObjectType
|
||||||
|
from .utils import bypass_get_queryset
|
||||||
|
|
||||||
__version__ = "3.0.0"
|
__version__ = "3.2.3"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"__version__",
|
"__version__",
|
||||||
"DjangoObjectType",
|
"DjangoObjectType",
|
||||||
"DjangoListField",
|
"DjangoListField",
|
||||||
"DjangoConnectionField",
|
"DjangoConnectionField",
|
||||||
|
"bypass_get_queryset",
|
||||||
]
|
]
|
||||||
|
|
|
@ -1,4 +1,14 @@
|
||||||
class MissingType(object):
|
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):
|
def __init__(self, *args, **kwargs):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
@ -7,19 +17,49 @@ try:
|
||||||
# Postgres fields are only available in Django with psycopg2 installed
|
# Postgres fields are only available in Django with psycopg2 installed
|
||||||
# and we cannot have psycopg2 on PyPy
|
# and we cannot have psycopg2 on PyPy
|
||||||
from django.contrib.postgres.fields import (
|
from django.contrib.postgres.fields import (
|
||||||
IntegerRangeField,
|
|
||||||
ArrayField,
|
ArrayField,
|
||||||
HStoreField,
|
HStoreField,
|
||||||
JSONField as PGJSONField,
|
IntegerRangeField,
|
||||||
RangeField,
|
RangeField,
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
IntegerRangeField, ArrayField, HStoreField, PGJSONField, RangeField = (
|
IntegerRangeField, HStoreField, RangeField = (MissingType,) * 3
|
||||||
MissingType,
|
|
||||||
) * 5
|
# 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:
|
try:
|
||||||
# JSONField is only available from Django 3.1
|
from django.utils.choices import normalize_choices
|
||||||
from django.db.models import JSONField
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
JSONField = MissingType
|
|
||||||
|
def normalize_choices(choices):
|
||||||
|
if isinstance(choices, type) and issubclass(choices, Choices):
|
||||||
|
choices = choices.choices
|
||||||
|
|
||||||
|
if isinstance(choices, Callable):
|
||||||
|
choices = choices()
|
||||||
|
|
||||||
|
# In restframework==3.15.0, choices are not passed
|
||||||
|
# as OrderedDict anymore, so it's safer to check
|
||||||
|
# for a dict
|
||||||
|
if isinstance(choices, dict):
|
||||||
|
choices = choices.items()
|
||||||
|
|
||||||
|
return choices
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
from collections import OrderedDict
|
import inspect
|
||||||
from functools import singledispatch, wraps
|
from functools import partial, singledispatch, wraps
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.encoding import force_str
|
from django.utils.encoding import force_str
|
||||||
from django.utils.functional import Promise
|
from django.utils.functional import Promise
|
||||||
from django.utils.module_loading import import_string
|
from django.utils.module_loading import import_string
|
||||||
|
from graphql import GraphQLError
|
||||||
|
|
||||||
from graphene import (
|
from graphene import (
|
||||||
ID,
|
ID,
|
||||||
|
@ -12,6 +13,7 @@ from graphene import (
|
||||||
Boolean,
|
Boolean,
|
||||||
Date,
|
Date,
|
||||||
DateTime,
|
DateTime,
|
||||||
|
Decimal,
|
||||||
Dynamic,
|
Dynamic,
|
||||||
Enum,
|
Enum,
|
||||||
Field,
|
Field,
|
||||||
|
@ -21,16 +23,21 @@ from graphene import (
|
||||||
NonNull,
|
NonNull,
|
||||||
String,
|
String,
|
||||||
Time,
|
Time,
|
||||||
Decimal,
|
|
||||||
)
|
)
|
||||||
from graphene.types.json import JSONString
|
from graphene.types.json import JSONString
|
||||||
|
from graphene.types.resolver import get_default_resolver
|
||||||
from graphene.types.scalars import BigInt
|
from graphene.types.scalars import BigInt
|
||||||
from graphene.utils.str_converters import to_camel_case
|
from graphene.utils.str_converters import to_camel_case
|
||||||
from graphql import GraphQLError, assert_valid_name
|
|
||||||
|
try:
|
||||||
|
from graphql import assert_name
|
||||||
|
except ImportError:
|
||||||
|
# Support for older versions of graphql
|
||||||
|
from graphql import assert_valid_name as assert_name
|
||||||
from graphql.pyutils import register_description
|
from graphql.pyutils import register_description
|
||||||
|
|
||||||
from .compat import ArrayField, HStoreField, JSONField, PGJSONField, RangeField
|
from .compat import ArrayField, HStoreField, RangeField, normalize_choices
|
||||||
from .fields import DjangoListField, DjangoConnectionField
|
from .fields import DjangoConnectionField, DjangoListField
|
||||||
from .settings import graphene_settings
|
from .settings import graphene_settings
|
||||||
from .utils.str_converters import to_const
|
from .utils.str_converters import to_const
|
||||||
|
|
||||||
|
@ -53,10 +60,28 @@ class BlankValueField(Field):
|
||||||
return blank_field_wrapper(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):
|
def convert_choice_name(name):
|
||||||
name = to_const(force_str(name))
|
name = to_const(force_str(name))
|
||||||
try:
|
try:
|
||||||
assert_valid_name(name)
|
assert_name(name)
|
||||||
except GraphQLError:
|
except GraphQLError:
|
||||||
name = "A_%s" % name
|
name = "A_%s" % name
|
||||||
return name
|
return name
|
||||||
|
@ -64,12 +89,10 @@ def convert_choice_name(name):
|
||||||
|
|
||||||
def get_choices(choices):
|
def get_choices(choices):
|
||||||
converted_names = []
|
converted_names = []
|
||||||
if isinstance(choices, OrderedDict):
|
choices = normalize_choices(choices)
|
||||||
choices = choices.items()
|
|
||||||
for value, help_text in choices:
|
for value, help_text in choices:
|
||||||
if isinstance(help_text, (tuple, list)):
|
if isinstance(help_text, (tuple, list)):
|
||||||
for choice in get_choices(help_text):
|
yield from get_choices(help_text)
|
||||||
yield choice
|
|
||||||
else:
|
else:
|
||||||
name = convert_choice_name(value)
|
name = convert_choice_name(value)
|
||||||
while name in converted_names:
|
while name in converted_names:
|
||||||
|
@ -86,12 +109,17 @@ def convert_choices_to_named_enum_with_descriptions(name, choices):
|
||||||
named_choices = [(c[0], c[1]) for c in choices]
|
named_choices = [(c[0], c[1]) for c in choices]
|
||||||
named_choices_descriptions = {c[0]: c[2] for c in choices}
|
named_choices_descriptions = {c[0]: c[2] for c in choices}
|
||||||
|
|
||||||
class EnumWithDescriptionsType(object):
|
class EnumWithDescriptionsType:
|
||||||
@property
|
@property
|
||||||
def description(self):
|
def description(self):
|
||||||
return str(named_choices_descriptions[self.name])
|
return str(named_choices_descriptions[self.name])
|
||||||
|
|
||||||
return_type = 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
|
return return_type
|
||||||
|
|
||||||
|
|
||||||
|
@ -103,7 +131,7 @@ def generate_enum_name(django_model_meta, field):
|
||||||
)
|
)
|
||||||
name = custom_func(field)
|
name = custom_func(field)
|
||||||
elif graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V2_NAMING is True:
|
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:
|
else:
|
||||||
name = "{app_label}{object_name}{field_name}Choices".format(
|
name = "{app_label}{object_name}{field_name}Choices".format(
|
||||||
app_label=to_camel_case(django_model_meta.app_label.title()),
|
app_label=to_camel_case(django_model_meta.app_label.title()),
|
||||||
|
@ -121,20 +149,24 @@ def convert_choice_field_to_enum(field, name=None):
|
||||||
|
|
||||||
|
|
||||||
def convert_django_field_with_choices(
|
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:
|
if registry is not None:
|
||||||
converted = registry.get_converted_field(field)
|
converted = registry.get_converted_field(field)
|
||||||
if converted:
|
if converted:
|
||||||
return converted
|
return converted
|
||||||
choices = getattr(field, "choices", None)
|
choices = getattr(field, "choices", None)
|
||||||
|
if convert_choices_to_enum is None:
|
||||||
|
convert_choices_to_enum = bool(
|
||||||
|
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CONVERT
|
||||||
|
)
|
||||||
if choices and convert_choices_to_enum:
|
if choices and convert_choices_to_enum:
|
||||||
EnumCls = convert_choice_field_to_enum(field)
|
EnumCls = convert_choice_field_to_enum(field)
|
||||||
required = not (field.blank or field.null)
|
required = not (field.blank or field.null)
|
||||||
|
|
||||||
converted = EnumCls(
|
converted = EnumCls(
|
||||||
description=get_django_field_description(field), required=required
|
description=get_django_field_description(field), required=required
|
||||||
).mount_as(BlankValueField)
|
).mount_as(EnumValueField)
|
||||||
else:
|
else:
|
||||||
converted = convert_django_field(field, registry)
|
converted = convert_django_field(field, registry)
|
||||||
if registry is not None:
|
if registry is not None:
|
||||||
|
@ -149,7 +181,7 @@ def get_django_field_description(field):
|
||||||
@singledispatch
|
@singledispatch
|
||||||
def convert_django_field(field, registry=None):
|
def convert_django_field(field, registry=None):
|
||||||
raise Exception(
|
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__})"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@ -167,19 +199,13 @@ def convert_field_to_string(field, registry=None):
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@convert_django_field.register(models.BigAutoField)
|
|
||||||
@convert_django_field.register(models.AutoField)
|
@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):
|
def convert_field_to_id(field, registry=None):
|
||||||
return ID(description=get_django_field_description(field), required=not field.null)
|
return ID(description=get_django_field_description(field), required=not field.null)
|
||||||
|
|
||||||
|
|
||||||
if hasattr(models, "SmallAutoField"):
|
|
||||||
|
|
||||||
@convert_django_field.register(models.SmallAutoField)
|
|
||||||
def convert_field_small_to_id(field, registry=None):
|
|
||||||
return convert_field_to_id(field, registry)
|
|
||||||
|
|
||||||
|
|
||||||
@convert_django_field.register(models.UUIDField)
|
@convert_django_field.register(models.UUIDField)
|
||||||
def convert_field_to_uuid(field, registry=None):
|
def convert_field_to_uuid(field, registry=None):
|
||||||
return UUID(
|
return UUID(
|
||||||
|
@ -246,6 +272,10 @@ def convert_time_to_string(field, registry=None):
|
||||||
|
|
||||||
@convert_django_field.register(models.OneToOneRel)
|
@convert_django_field.register(models.OneToOneRel)
|
||||||
def convert_onetoone_field_to_djangomodel(field, registry=None):
|
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
|
model = field.related_model
|
||||||
|
|
||||||
def dynamic_type():
|
def dynamic_type():
|
||||||
|
@ -253,7 +283,55 @@ def convert_onetoone_field_to_djangomodel(field, registry=None):
|
||||||
if not _type:
|
if not _type:
|
||||||
return
|
return
|
||||||
|
|
||||||
return Field(_type, required=not field.null)
|
class CustomField(Field):
|
||||||
|
def wrap_resolve(self, parent_resolver):
|
||||||
|
"""
|
||||||
|
Implements a custom resolver which goes through the `get_node` method to ensure that
|
||||||
|
it goes through the `get_queryset` method of the DjangoObjectType.
|
||||||
|
"""
|
||||||
|
resolver = super().wrap_resolve(parent_resolver)
|
||||||
|
|
||||||
|
# If `get_queryset` was not overridden in the DjangoObjectType
|
||||||
|
# or if we explicitly bypass the `get_queryset` method,
|
||||||
|
# we can just return the default resolver.
|
||||||
|
if (
|
||||||
|
_type.get_queryset.__func__
|
||||||
|
is DjangoObjectType.get_queryset.__func__
|
||||||
|
or getattr(resolver, "_bypass_get_queryset", False)
|
||||||
|
):
|
||||||
|
return resolver
|
||||||
|
|
||||||
|
def custom_resolver(root, info, **args):
|
||||||
|
# Note: this function is used to resolve 1:1 relation fields
|
||||||
|
|
||||||
|
is_resolver_awaitable = inspect.iscoroutinefunction(resolver)
|
||||||
|
|
||||||
|
if is_resolver_awaitable:
|
||||||
|
fk_obj = resolver(root, info, **args)
|
||||||
|
# In case the resolver is a custom awaitable resolver that overwrites
|
||||||
|
# the default Django resolver
|
||||||
|
return fk_obj
|
||||||
|
|
||||||
|
field_name = to_snake_case(info.field_name)
|
||||||
|
reversed_field_name = root.__class__._meta.get_field(
|
||||||
|
field_name
|
||||||
|
).remote_field.name
|
||||||
|
try:
|
||||||
|
return _type.get_queryset(
|
||||||
|
_type._meta.model.objects.filter(
|
||||||
|
**{reversed_field_name: root.pk}
|
||||||
|
),
|
||||||
|
info,
|
||||||
|
).get()
|
||||||
|
except _type._meta.model.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return custom_resolver
|
||||||
|
|
||||||
|
return CustomField(
|
||||||
|
_type,
|
||||||
|
required=not field.null,
|
||||||
|
)
|
||||||
|
|
||||||
return Dynamic(dynamic_type)
|
return Dynamic(dynamic_type)
|
||||||
|
|
||||||
|
@ -301,6 +379,10 @@ def convert_field_to_list_or_connection(field, registry=None):
|
||||||
@convert_django_field.register(models.OneToOneField)
|
@convert_django_field.register(models.OneToOneField)
|
||||||
@convert_django_field.register(models.ForeignKey)
|
@convert_django_field.register(models.ForeignKey)
|
||||||
def convert_field_to_djangomodel(field, registry=None):
|
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
|
model = field.related_model
|
||||||
|
|
||||||
def dynamic_type():
|
def dynamic_type():
|
||||||
|
@ -311,17 +393,72 @@ def convert_field_to_djangomodel(field, registry=None):
|
||||||
class CustomField(Field):
|
class CustomField(Field):
|
||||||
def wrap_resolve(self, parent_resolver):
|
def wrap_resolve(self, parent_resolver):
|
||||||
"""
|
"""
|
||||||
Implements a custom resolver which go through the `get_node` method to insure that
|
Implements a custom resolver which goes through the `get_node` method to ensure that
|
||||||
it goes through the `get_queryset` method of the DjangoObjectType.
|
it goes through the `get_queryset` method of the DjangoObjectType.
|
||||||
"""
|
"""
|
||||||
resolver = super().wrap_resolve(parent_resolver)
|
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):
|
def custom_resolver(root, info, **args):
|
||||||
fk_obj = resolver(root, info, **args)
|
# Note: this function is used to resolve FK or 1:1 fields
|
||||||
if fk_obj is None:
|
# it does not differentiate between custom-resolved fields
|
||||||
return None
|
# 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:
|
else:
|
||||||
return _type.get_node(info, fk_obj.pk)
|
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 custom_resolver
|
||||||
|
|
||||||
|
@ -351,9 +488,8 @@ def convert_postgres_array_to_list(field, registry=None):
|
||||||
|
|
||||||
|
|
||||||
@convert_django_field.register(HStoreField)
|
@convert_django_field.register(HStoreField)
|
||||||
@convert_django_field.register(PGJSONField)
|
@convert_django_field.register(models.JSONField)
|
||||||
@convert_django_field.register(JSONField)
|
def convert_json_field_to_string(field, registry=None):
|
||||||
def convert_pg_and_json_field_to_string(field, registry=None):
|
|
||||||
return JSONString(
|
return JSONString(
|
||||||
description=get_django_field_description(field), required=not field.null
|
description=get_django_field_description(field), required=not field.null
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,40 +1,38 @@
|
||||||
from django.db import connections
|
from django.db import connections
|
||||||
|
|
||||||
from promise import Promise
|
|
||||||
|
|
||||||
from .sql.tracking import unwrap_cursor, wrap_cursor
|
|
||||||
from .exception.formating import wrap_exception
|
from .exception.formating import wrap_exception
|
||||||
|
from .sql.tracking import unwrap_cursor, wrap_cursor
|
||||||
from .types import DjangoDebug
|
from .types import DjangoDebug
|
||||||
|
|
||||||
|
|
||||||
class DjangoDebugContext(object):
|
class DjangoDebugContext:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.debug_promise = None
|
self.debug_result = None
|
||||||
self.promises = []
|
self.results = []
|
||||||
self.object = DjangoDebug(sql=[], exceptions=[])
|
self.object = DjangoDebug(sql=[], exceptions=[])
|
||||||
self.enable_instrumentation()
|
self.enable_instrumentation()
|
||||||
|
|
||||||
def get_debug_promise(self):
|
def get_debug_result(self):
|
||||||
if not self.debug_promise:
|
if not self.debug_result:
|
||||||
self.debug_promise = Promise.all(self.promises)
|
self.debug_result = self.results
|
||||||
self.promises = []
|
self.results = []
|
||||||
return self.debug_promise.then(self.on_resolve_all_promises).get()
|
return self.on_resolve_all_results()
|
||||||
|
|
||||||
def on_resolve_error(self, value):
|
def on_resolve_error(self, value):
|
||||||
if hasattr(self, "object"):
|
if hasattr(self, "object"):
|
||||||
self.object.exceptions.append(wrap_exception(value))
|
self.object.exceptions.append(wrap_exception(value))
|
||||||
return Promise.reject(value)
|
return value
|
||||||
|
|
||||||
def on_resolve_all_promises(self, values):
|
def on_resolve_all_results(self):
|
||||||
if self.promises:
|
if self.results:
|
||||||
self.debug_promise = None
|
self.debug_result = None
|
||||||
return self.get_debug_promise()
|
return self.get_debug_result()
|
||||||
self.disable_instrumentation()
|
self.disable_instrumentation()
|
||||||
return self.object
|
return self.object
|
||||||
|
|
||||||
def add_promise(self, promise):
|
def add_result(self, result):
|
||||||
if self.debug_promise:
|
if self.debug_result:
|
||||||
self.promises.append(promise)
|
self.results.append(result)
|
||||||
|
|
||||||
def enable_instrumentation(self):
|
def enable_instrumentation(self):
|
||||||
# This is thread-safe because database connections are thread-local.
|
# This is thread-safe because database connections are thread-local.
|
||||||
|
@ -46,7 +44,7 @@ class DjangoDebugContext(object):
|
||||||
unwrap_cursor(connection)
|
unwrap_cursor(connection)
|
||||||
|
|
||||||
|
|
||||||
class DjangoDebugMiddleware(object):
|
class DjangoDebugMiddleware:
|
||||||
def resolve(self, next, root, info, **args):
|
def resolve(self, next, root, info, **args):
|
||||||
context = info.context
|
context = info.context
|
||||||
django_debug = getattr(context, "django_debug", None)
|
django_debug = getattr(context, "django_debug", None)
|
||||||
|
@ -62,10 +60,10 @@ class DjangoDebugMiddleware(object):
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if info.schema.get_type("DjangoDebug") == info.return_type:
|
if info.schema.get_type("DjangoDebug") == info.return_type:
|
||||||
return context.django_debug.get_debug_promise()
|
return context.django_debug.get_debug_result()
|
||||||
try:
|
try:
|
||||||
promise = next(root, info, **args)
|
result = next(root, info, **args)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return context.django_debug.on_resolve_error(e)
|
return context.django_debug.on_resolve_error(e)
|
||||||
context.django_debug.add_promise(promise)
|
context.django_debug.add_result(result)
|
||||||
return promise
|
return result
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
# Code obtained from django-debug-toolbar sql panel tracking
|
# Code obtained from django-debug-toolbar sql panel tracking
|
||||||
from __future__ import absolute_import, unicode_literals
|
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from threading import local
|
from threading import local
|
||||||
|
@ -50,7 +49,7 @@ def unwrap_cursor(connection):
|
||||||
del connection._graphene_cursor
|
del connection._graphene_cursor
|
||||||
|
|
||||||
|
|
||||||
class ExceptionCursorWrapper(object):
|
class ExceptionCursorWrapper:
|
||||||
"""
|
"""
|
||||||
Wraps a cursor and raises an exception on any operation.
|
Wraps a cursor and raises an exception on any operation.
|
||||||
Used in Templates panel.
|
Used in Templates panel.
|
||||||
|
@ -63,7 +62,7 @@ class ExceptionCursorWrapper(object):
|
||||||
raise SQLQueryTriggered()
|
raise SQLQueryTriggered()
|
||||||
|
|
||||||
|
|
||||||
class NormalCursorWrapper(object):
|
class NormalCursorWrapper:
|
||||||
"""
|
"""
|
||||||
Wraps a cursor and logs queries.
|
Wraps a cursor and logs queries.
|
||||||
"""
|
"""
|
||||||
|
@ -85,7 +84,7 @@ class NormalCursorWrapper(object):
|
||||||
if not params:
|
if not params:
|
||||||
return params
|
return params
|
||||||
if isinstance(params, dict):
|
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))
|
return list(map(self._quote_expr, params))
|
||||||
|
|
||||||
def _decode(self, param):
|
def _decode(self, param):
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
import graphene
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
import graphene
|
||||||
from graphene.relay import Node
|
from graphene.relay import Node
|
||||||
from graphene_django import DjangoConnectionField, DjangoObjectType
|
from graphene_django import DjangoConnectionField, DjangoObjectType
|
||||||
|
|
||||||
|
@ -8,7 +9,7 @@ from ..middleware import DjangoDebugMiddleware
|
||||||
from ..types import DjangoDebug
|
from ..types import DjangoDebug
|
||||||
|
|
||||||
|
|
||||||
class context(object):
|
class context:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
from graphene import List, ObjectType
|
from graphene import List, ObjectType
|
||||||
|
|
||||||
from .sql.types import DjangoDebugSQL
|
|
||||||
from .exception.types import DjangoDebugException
|
from .exception.types import DjangoDebugException
|
||||||
|
from .sql.types import DjangoDebugSQL
|
||||||
|
|
||||||
|
|
||||||
class DjangoDebug(ObjectType):
|
class DjangoDebug(ObjectType):
|
||||||
|
|
|
@ -1,14 +1,12 @@
|
||||||
from functools import partial
|
from functools import partial
|
||||||
|
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
|
|
||||||
from graphql_relay import (
|
from graphql_relay import (
|
||||||
connection_from_array_slice,
|
connection_from_array_slice,
|
||||||
cursor_to_offset,
|
cursor_to_offset,
|
||||||
get_offset_with_default,
|
get_offset_with_default,
|
||||||
offset_to_cursor,
|
offset_to_cursor,
|
||||||
)
|
)
|
||||||
|
|
||||||
from promise import Promise
|
from promise import Promise
|
||||||
|
|
||||||
from graphene import Int, NonNull
|
from graphene import Int, NonNull
|
||||||
|
@ -22,17 +20,20 @@ from .utils import maybe_queryset
|
||||||
|
|
||||||
class DjangoListField(Field):
|
class DjangoListField(Field):
|
||||||
def __init__(self, _type, *args, **kwargs):
|
def __init__(self, _type, *args, **kwargs):
|
||||||
from .types import DjangoObjectType
|
|
||||||
|
|
||||||
if isinstance(_type, NonNull):
|
if isinstance(_type, NonNull):
|
||||||
_type = _type.of_type
|
_type = _type.of_type
|
||||||
|
|
||||||
# Django would never return a Set of None vvvvvvv
|
# 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(
|
assert issubclass(
|
||||||
self._underlying_type, DjangoObjectType
|
self._underlying_type, DjangoObjectType
|
||||||
), "DjangoListField only accepts DjangoObjectType types"
|
), "DjangoListField only accepts DjangoObjectType types as underlying type"
|
||||||
|
return super().type
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _underlying_type(self):
|
def _underlying_type(self):
|
||||||
|
@ -63,7 +64,7 @@ class DjangoListField(Field):
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
def wrap_resolve(self, parent_resolver):
|
def wrap_resolve(self, parent_resolver):
|
||||||
resolver = super(DjangoListField, self).wrap_resolve(parent_resolver)
|
resolver = super().wrap_resolve(parent_resolver)
|
||||||
_type = self.type
|
_type = self.type
|
||||||
if isinstance(_type, NonNull):
|
if isinstance(_type, NonNull):
|
||||||
_type = _type.of_type
|
_type = _type.of_type
|
||||||
|
@ -87,7 +88,7 @@ class DjangoConnectionField(ConnectionField):
|
||||||
graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST,
|
graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST,
|
||||||
)
|
)
|
||||||
kwargs.setdefault("offset", Int())
|
kwargs.setdefault("offset", Int())
|
||||||
super(DjangoConnectionField, self).__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def type(self):
|
def type(self):
|
||||||
|
@ -196,7 +197,7 @@ class DjangoConnectionField(ConnectionField):
|
||||||
enforce_first_or_last,
|
enforce_first_or_last,
|
||||||
root,
|
root,
|
||||||
info,
|
info,
|
||||||
**args
|
**args,
|
||||||
):
|
):
|
||||||
first = args.get("first")
|
first = args.get("first")
|
||||||
last = args.get("last")
|
last = args.get("last")
|
||||||
|
@ -246,7 +247,7 @@ class DjangoConnectionField(ConnectionField):
|
||||||
def wrap_resolve(self, parent_resolver):
|
def wrap_resolve(self, parent_resolver):
|
||||||
return partial(
|
return partial(
|
||||||
self.connection_resolver,
|
self.connection_resolver,
|
||||||
parent_resolver,
|
self.resolver or parent_resolver,
|
||||||
self.connection_type,
|
self.connection_type,
|
||||||
self.get_manager(),
|
self.get_manager(),
|
||||||
self.get_queryset_resolver(),
|
self.get_queryset_resolver(),
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from ..utils import DJANGO_FILTER_INSTALLED
|
from ..utils import DJANGO_FILTER_INSTALLED
|
||||||
|
|
||||||
if not DJANGO_FILTER_INSTALLED:
|
if not DJANGO_FILTER_INSTALLED:
|
||||||
|
|
|
@ -3,8 +3,8 @@ from functools import partial
|
||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
from graphene.types.enum import EnumType
|
|
||||||
from graphene.types.argument import to_arguments
|
from graphene.types.argument import to_arguments
|
||||||
|
from graphene.types.enum import EnumType
|
||||||
from graphene.utils.str_converters import to_snake_case
|
from graphene.utils.str_converters import to_snake_case
|
||||||
|
|
||||||
from ..fields import DjangoConnectionField
|
from ..fields import DjangoConnectionField
|
||||||
|
@ -36,7 +36,7 @@ class DjangoFilterConnectionField(DjangoConnectionField):
|
||||||
extra_filter_meta=None,
|
extra_filter_meta=None,
|
||||||
filterset_class=None,
|
filterset_class=None,
|
||||||
*args,
|
*args,
|
||||||
**kwargs
|
**kwargs,
|
||||||
):
|
):
|
||||||
self._fields = fields
|
self._fields = fields
|
||||||
self._provided_filterset_class = filterset_class
|
self._provided_filterset_class = filterset_class
|
||||||
|
@ -44,7 +44,7 @@ class DjangoFilterConnectionField(DjangoConnectionField):
|
||||||
self._filtering_args = None
|
self._filtering_args = None
|
||||||
self._extra_filter_meta = extra_filter_meta
|
self._extra_filter_meta = extra_filter_meta
|
||||||
self._base_args = None
|
self._base_args = None
|
||||||
super(DjangoFilterConnectionField, self).__init__(type_, *args, **kwargs)
|
super().__init__(type_, *args, **kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def args(self):
|
def args(self):
|
||||||
|
@ -58,7 +58,7 @@ class DjangoFilterConnectionField(DjangoConnectionField):
|
||||||
def filterset_class(self):
|
def filterset_class(self):
|
||||||
if not self._filterset_class:
|
if not self._filterset_class:
|
||||||
fields = self._fields or self.node_type._meta.filter_fields
|
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:
|
if self._extra_filter_meta:
|
||||||
meta.update(self._extra_filter_meta)
|
meta.update(self._extra_filter_meta)
|
||||||
|
|
||||||
|
@ -90,9 +90,7 @@ class DjangoFilterConnectionField(DjangoConnectionField):
|
||||||
kwargs[k] = convert_enum(v)
|
kwargs[k] = convert_enum(v)
|
||||||
return kwargs
|
return kwargs
|
||||||
|
|
||||||
qs = super(DjangoFilterConnectionField, cls).resolve_queryset(
|
qs = super().resolve_queryset(connection, iterable, info, args)
|
||||||
connection, iterable, info, args
|
|
||||||
)
|
|
||||||
|
|
||||||
filterset = filterset_class(
|
filterset = filterset_class(
|
||||||
data=filter_kwargs(), queryset=qs, request=info.context
|
data=filter_kwargs(), queryset=qs, request=info.context
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import warnings
|
import warnings
|
||||||
|
|
||||||
from ...utils import DJANGO_FILTER_INSTALLED
|
from ...utils import DJANGO_FILTER_INSTALLED
|
||||||
|
|
||||||
if not DJANGO_FILTER_INSTALLED:
|
if not DJANGO_FILTER_INSTALLED:
|
||||||
|
|
|
@ -1,13 +1,36 @@
|
||||||
from django_filters.constants import EMPTY_VALUES
|
from django_filters.constants import EMPTY_VALUES
|
||||||
|
from django_filters.filters import FilterMethod
|
||||||
|
|
||||||
from .typed_filter import TypedFilter
|
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):
|
class ArrayFilter(TypedFilter):
|
||||||
"""
|
"""
|
||||||
Filter made for PostgreSQL ArrayField.
|
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):
|
def filter(self, qs, value):
|
||||||
"""
|
"""
|
||||||
Override the default filter class to check first whether the list is
|
Override the default filter class to check first whether the list is
|
||||||
|
@ -22,6 +45,6 @@ class ArrayFilter(TypedFilter):
|
||||||
return qs
|
return qs
|
||||||
if self.distinct:
|
if self.distinct:
|
||||||
qs = qs.distinct()
|
qs = qs.distinct()
|
||||||
lookup = "%s__%s" % (self.field_name, self.lookup_expr)
|
lookup = f"{self.field_name}__{self.lookup_expr}"
|
||||||
qs = self.get_method(qs)(**{lookup: value})
|
qs = self.get_method(qs)(**{lookup: value})
|
||||||
return qs
|
return qs
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
from django_filters import Filter, MultipleChoiceFilter
|
from django_filters import Filter, MultipleChoiceFilter
|
||||||
|
|
||||||
from graphql_relay.node.node import from_global_id
|
from graphql_relay.node.node import from_global_id
|
||||||
|
|
||||||
from ...forms import GlobalIDFormField, GlobalIDMultipleChoiceField
|
from ...forms import GlobalIDFormField, GlobalIDMultipleChoiceField
|
||||||
|
@ -17,7 +16,7 @@ class GlobalIDFilter(Filter):
|
||||||
_id = None
|
_id = None
|
||||||
if value is not None:
|
if value is not None:
|
||||||
_, _id = from_global_id(value)
|
_, _id = from_global_id(value)
|
||||||
return super(GlobalIDFilter, self).filter(qs, _id)
|
return super().filter(qs, _id)
|
||||||
|
|
||||||
|
|
||||||
class GlobalIDMultipleChoiceFilter(MultipleChoiceFilter):
|
class GlobalIDMultipleChoiceFilter(MultipleChoiceFilter):
|
||||||
|
@ -25,4 +24,4 @@ class GlobalIDMultipleChoiceFilter(MultipleChoiceFilter):
|
||||||
|
|
||||||
def filter(self, qs, value):
|
def filter(self, qs, value):
|
||||||
gids = [from_global_id(v)[1] for v in value]
|
gids = [from_global_id(v)[1] for v in value]
|
||||||
return super(GlobalIDMultipleChoiceFilter, self).filter(qs, gids)
|
return super().filter(qs, gids)
|
||||||
|
|
|
@ -1,12 +1,36 @@
|
||||||
|
from django_filters.filters import FilterMethod
|
||||||
|
|
||||||
from .typed_filter import TypedFilter
|
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):
|
class ListFilter(TypedFilter):
|
||||||
"""
|
"""
|
||||||
Filter that takes a list of value as input.
|
Filter that takes a list of value as input.
|
||||||
It is for example used for `__in` filters.
|
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):
|
def filter(self, qs, value):
|
||||||
"""
|
"""
|
||||||
Override the default filter class to check first whether the list is
|
Override the default filter class to check first whether the list is
|
||||||
|
@ -23,4 +47,4 @@ class ListFilter(TypedFilter):
|
||||||
else:
|
else:
|
||||||
return qs.none()
|
return qs.none()
|
||||||
else:
|
else:
|
||||||
return super(ListFilter, self).filter(qs, value)
|
return super().filter(qs, value)
|
||||||
|
|
|
@ -12,7 +12,7 @@ class TypedFilter(Filter):
|
||||||
|
|
||||||
def __init__(self, input_type=None, *args, **kwargs):
|
def __init__(self, input_type=None, *args, **kwargs):
|
||||||
self._input_type = input_type
|
self._input_type = input_type
|
||||||
super(TypedFilter, self).__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def input_type(self):
|
def input_type(self):
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
import itertools
|
import itertools
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django_filters.filterset import BaseFilterSet, FilterSet
|
from django_filters.filterset import (
|
||||||
from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS
|
FILTER_FOR_DBFIELD_DEFAULTS,
|
||||||
|
BaseFilterSet,
|
||||||
|
FilterSet,
|
||||||
|
)
|
||||||
|
|
||||||
from .filters import GlobalIDFilter, GlobalIDMultipleChoiceFilter
|
from .filters import GlobalIDFilter, GlobalIDMultipleChoiceFilter
|
||||||
|
|
||||||
|
|
||||||
GRAPHENE_FILTER_SET_OVERRIDES = {
|
GRAPHENE_FILTER_SET_OVERRIDES = {
|
||||||
models.AutoField: {"filter_class": GlobalIDFilter},
|
models.AutoField: {"filter_class": GlobalIDFilter},
|
||||||
models.OneToOneField: {"filter_class": GlobalIDFilter},
|
models.OneToOneField: {"filter_class": GlobalIDFilter},
|
||||||
|
@ -31,7 +33,7 @@ class GrapheneFilterSetMixin(BaseFilterSet):
|
||||||
def setup_filterset(filterset_class):
|
def setup_filterset(filterset_class):
|
||||||
"""Wrap a provided filterset in Graphene-specific functionality"""
|
"""Wrap a provided filterset in Graphene-specific functionality"""
|
||||||
return type(
|
return type(
|
||||||
"Graphene{}".format(filterset_class.__name__),
|
f"Graphene{filterset_class.__name__}",
|
||||||
(filterset_class, GrapheneFilterSetMixin),
|
(filterset_class, GrapheneFilterSetMixin),
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
|
@ -40,7 +42,7 @@ def setup_filterset(filterset_class):
|
||||||
def custom_filterset_factory(model, filterset_base_class=FilterSet, **meta):
|
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.update({"model": model})
|
||||||
meta_class = type(str("Meta"), (object,), meta)
|
meta_class = type("Meta", (object,), meta)
|
||||||
filterset = type(
|
filterset = type(
|
||||||
str("%sFilterSet" % model._meta.object_name),
|
str("%sFilterSet" % model._meta.object_name),
|
||||||
(filterset_base_class, GrapheneFilterSetMixin),
|
(filterset_base_class, GrapheneFilterSetMixin),
|
||||||
|
|
|
@ -1,15 +1,15 @@
|
||||||
from mock import MagicMock
|
from functools import reduce
|
||||||
import pytest
|
|
||||||
|
|
||||||
|
import pytest
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models.query import QuerySet
|
from django.db.models.query import QuerySet
|
||||||
from django_filters import filters
|
|
||||||
from django_filters import FilterSet
|
from django_filters import FilterSet
|
||||||
|
|
||||||
import graphene
|
import graphene
|
||||||
from graphene.relay import Node
|
from graphene.relay import Node
|
||||||
from graphene_django import DjangoObjectType
|
from graphene_django import DjangoObjectType
|
||||||
|
from graphene_django.filter import ArrayFilter
|
||||||
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
||||||
from graphene_django.filter import ArrayFilter, ListFilter
|
|
||||||
|
|
||||||
from ...compat import ArrayField
|
from ...compat import ArrayField
|
||||||
|
|
||||||
|
@ -25,15 +25,15 @@ else:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
STORE = {"events": []}
|
|
||||||
|
|
||||||
|
|
||||||
class Event(models.Model):
|
class Event(models.Model):
|
||||||
name = models.CharField(max_length=50)
|
name = models.CharField(max_length=50)
|
||||||
tags = ArrayField(models.CharField(max_length=50))
|
tags = ArrayField(models.CharField(max_length=50))
|
||||||
tag_ids = ArrayField(models.IntegerField())
|
tag_ids = ArrayField(models.IntegerField())
|
||||||
random_field = ArrayField(models.BooleanField())
|
random_field = ArrayField(models.BooleanField())
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"Event [{self.name}]"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def EventFilterSet():
|
def EventFilterSet():
|
||||||
|
@ -44,10 +44,18 @@ def EventFilterSet():
|
||||||
"name": ["exact", "contains"],
|
"name": ["exact", "contains"],
|
||||||
}
|
}
|
||||||
|
|
||||||
# Those are actually usable with our Query fixture bellow
|
# Those are actually usable with our Query fixture below
|
||||||
tags__contains = ArrayFilter(field_name="tags", lookup_expr="contains")
|
tags__contains = ArrayFilter(field_name="tags", lookup_expr="contains")
|
||||||
tags__overlap = ArrayFilter(field_name="tags", lookup_expr="overlap")
|
tags__overlap = ArrayFilter(field_name="tags", lookup_expr="overlap")
|
||||||
tags = ArrayFilter(field_name="tags", lookup_expr="exact")
|
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
|
# Those are actually not usable and only to check type declarations
|
||||||
tags_ids__contains = ArrayFilter(field_name="tag_ids", lookup_expr="contains")
|
tags_ids__contains = ArrayFilter(field_name="tag_ids", lookup_expr="contains")
|
||||||
|
@ -61,6 +69,14 @@ def EventFilterSet():
|
||||||
)
|
)
|
||||||
random_field = ArrayFilter(field_name="random_field", lookup_expr="exact")
|
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
|
return EventFilterSet
|
||||||
|
|
||||||
|
|
||||||
|
@ -83,69 +99,94 @@ def Query(EventType):
|
||||||
we are running unit tests in sqlite which does not have ArrayFields.
|
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):
|
class Query(graphene.ObjectType):
|
||||||
events = DjangoFilterConnectionField(EventType)
|
events = DjangoFilterConnectionField(EventType)
|
||||||
|
|
||||||
def resolve_events(self, info, **kwargs):
|
def resolve_events(self, info, **kwargs):
|
||||||
|
class FakeQuerySet(QuerySet):
|
||||||
|
def __init__(self, model=None):
|
||||||
|
self.model = Event
|
||||||
|
self.__store = list(events)
|
||||||
|
|
||||||
events = [
|
def all(self):
|
||||||
Event(name="Live Show", tags=["concert", "music", "rock"]),
|
return self
|
||||||
Event(name="Musical", tags=["movie", "music"]),
|
|
||||||
Event(name="Ballet", tags=["concert", "dance"]),
|
|
||||||
Event(name="Speech", tags=[]),
|
|
||||||
]
|
|
||||||
|
|
||||||
STORE["events"] = events
|
def filter(self, **kwargs):
|
||||||
|
queryset = FakeQuerySet()
|
||||||
m_queryset = MagicMock(spec=QuerySet)
|
queryset.__store = list(self.__store)
|
||||||
m_queryset.model = Event
|
if "tags__contains" in kwargs:
|
||||||
|
queryset.__store = list(
|
||||||
def filter_events(**kwargs):
|
filter(
|
||||||
if "tags__contains" in kwargs:
|
lambda e: set(kwargs["tags__contains"]).issubset(
|
||||||
STORE["events"] = list(
|
set(e.tags)
|
||||||
filter(
|
),
|
||||||
lambda e: set(kwargs["tags__contains"]).issubset(
|
queryset.__store,
|
||||||
set(e.tags)
|
)
|
||||||
),
|
|
||||||
STORE["events"],
|
|
||||||
)
|
)
|
||||||
)
|
if "tags__overlap" in kwargs:
|
||||||
if "tags__overlap" in kwargs:
|
queryset.__store = list(
|
||||||
STORE["events"] = list(
|
filter(
|
||||||
filter(
|
lambda e: not set(kwargs["tags__overlap"]).isdisjoint(
|
||||||
lambda e: not set(kwargs["tags__overlap"]).isdisjoint(
|
set(e.tags)
|
||||||
set(e.tags)
|
),
|
||||||
),
|
queryset.__store,
|
||||||
STORE["events"],
|
)
|
||||||
)
|
)
|
||||||
)
|
if "tags__exact" in kwargs:
|
||||||
if "tags__exact" in kwargs:
|
queryset.__store = list(
|
||||||
STORE["events"] = list(
|
filter(
|
||||||
filter(
|
lambda e: set(kwargs["tags__exact"]) == set(e.tags),
|
||||||
lambda e: set(kwargs["tags__exact"]) == set(e.tags),
|
queryset.__store,
|
||||||
STORE["events"],
|
)
|
||||||
)
|
)
|
||||||
)
|
if "tags__len" in kwargs:
|
||||||
|
queryset.__store = list(
|
||||||
|
filter(
|
||||||
|
lambda e: len(e.tags) == kwargs["tags__len"],
|
||||||
|
queryset.__store,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return queryset
|
||||||
|
|
||||||
def mock_queryset_filter(*args, **kwargs):
|
def union(self, *args):
|
||||||
filter_events(**kwargs)
|
queryset = FakeQuerySet()
|
||||||
return m_queryset
|
queryset.__store = self.__store
|
||||||
|
for arg in args:
|
||||||
|
queryset.__store += arg.__store
|
||||||
|
return queryset
|
||||||
|
|
||||||
def mock_queryset_none(*args, **kwargs):
|
def none(self):
|
||||||
STORE["events"] = []
|
queryset = FakeQuerySet()
|
||||||
return m_queryset
|
queryset.__store = []
|
||||||
|
return queryset
|
||||||
|
|
||||||
def mock_queryset_count(*args, **kwargs):
|
def count(self):
|
||||||
return len(STORE["events"])
|
return len(self.__store)
|
||||||
|
|
||||||
m_queryset.all.return_value = m_queryset
|
def distinct(self):
|
||||||
m_queryset.filter.side_effect = mock_queryset_filter
|
queryset = FakeQuerySet()
|
||||||
m_queryset.none.side_effect = mock_queryset_none
|
queryset.__store = []
|
||||||
m_queryset.count.side_effect = mock_queryset_count
|
for event in self.__store:
|
||||||
m_queryset.__getitem__.side_effect = lambda index: STORE[
|
if event not in queryset.__store:
|
||||||
"events"
|
queryset.__store.append(event)
|
||||||
].__getitem__(index)
|
queryset.__store = sorted(queryset.__store, key=lambda e: e.name)
|
||||||
|
return queryset
|
||||||
|
|
||||||
return m_queryset
|
def __getitem__(self, index):
|
||||||
|
return self.__store[index]
|
||||||
|
|
||||||
|
return FakeQuerySet()
|
||||||
|
|
||||||
return Query
|
return Query
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def schema(Query):
|
||||||
|
return graphene.Schema(query=Query)
|
||||||
|
|
|
@ -1,18 +1,14 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from graphene import Schema
|
|
||||||
|
|
||||||
from ...compat import ArrayField, MissingType
|
from ...compat import ArrayField, MissingType
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||||
def test_array_field_contains_multiple(Query):
|
def test_array_field_contains_multiple(schema):
|
||||||
"""
|
"""
|
||||||
Test contains filter on a array field of string.
|
Test contains filter on a array field of string.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
schema = Schema(query=Query)
|
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
query {
|
query {
|
||||||
events (tags_Contains: ["concert", "music"]) {
|
events (tags_Contains: ["concert", "music"]) {
|
||||||
|
@ -32,13 +28,11 @@ def test_array_field_contains_multiple(Query):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||||
def test_array_field_contains_one(Query):
|
def test_array_field_contains_one(schema):
|
||||||
"""
|
"""
|
||||||
Test contains filter on a array field of string.
|
Test contains filter on a array field of string.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
schema = Schema(query=Query)
|
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
query {
|
query {
|
||||||
events (tags_Contains: ["music"]) {
|
events (tags_Contains: ["music"]) {
|
||||||
|
@ -59,13 +53,11 @@ def test_array_field_contains_one(Query):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||||
def test_array_field_contains_empty_list(Query):
|
def test_array_field_contains_empty_list(schema):
|
||||||
"""
|
"""
|
||||||
Test contains filter on a array field of string.
|
Test contains filter on a array field of string.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
schema = Schema(query=Query)
|
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
query {
|
query {
|
||||||
events (tags_Contains: []) {
|
events (tags_Contains: []) {
|
||||||
|
|
186
graphene_django/filter/tests/test_array_field_custom_filter.py
Normal file
186
graphene_django/filter/tests/test_array_field_custom_filter.py
Normal file
|
@ -0,0 +1,186 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ...compat import ArrayField, MissingType
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||||
|
def test_array_field_len_filter(schema):
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
events (tags_Len: 2) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["events"]["edges"] == [
|
||||||
|
{"node": {"name": "Musical"}},
|
||||||
|
{"node": {"name": "Ballet"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
events (tags_Len: 0) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["events"]["edges"] == [
|
||||||
|
{"node": {"name": "Speech"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
events (tags_Len: 10) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["events"]["edges"] == []
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
events (tags_Len: "2") {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert len(result.errors) == 1
|
||||||
|
assert result.errors[0].message == 'Int cannot represent non-integer value: "2"'
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
events (tags_Len: True) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert len(result.errors) == 1
|
||||||
|
assert result.errors[0].message == "Int cannot represent non-integer value: True"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||||
|
def test_array_field_custom_filter(schema):
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
events (tags_Len_In: 2) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["events"]["edges"] == [
|
||||||
|
{"node": {"name": "Ballet"}},
|
||||||
|
{"node": {"name": "Musical"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
events (tags_Len_In: [0, 2]) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["events"]["edges"] == [
|
||||||
|
{"node": {"name": "Ballet"}},
|
||||||
|
{"node": {"name": "Musical"}},
|
||||||
|
{"node": {"name": "Speech"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
events (tags_Len_In: [10]) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["events"]["edges"] == []
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
events (tags_Len_In: []) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["events"]["edges"] == []
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
events (tags_Len_In: "12") {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert len(result.errors) == 1
|
||||||
|
assert result.errors[0].message == 'Int cannot represent non-integer value: "12"'
|
||||||
|
|
||||||
|
query = """
|
||||||
|
query {
|
||||||
|
events (tags_Len_In: True) {
|
||||||
|
edges {
|
||||||
|
node {
|
||||||
|
name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert len(result.errors) == 1
|
||||||
|
assert result.errors[0].message == "Int cannot represent non-integer value: True"
|
|
@ -1,18 +1,14 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from graphene import Schema
|
|
||||||
|
|
||||||
from ...compat import ArrayField, MissingType
|
from ...compat import ArrayField, MissingType
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||||
def test_array_field_exact_no_match(Query):
|
def test_array_field_exact_no_match(schema):
|
||||||
"""
|
"""
|
||||||
Test exact filter on a array field of string.
|
Test exact filter on a array field of string.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
schema = Schema(query=Query)
|
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
query {
|
query {
|
||||||
events (tags: ["concert", "music"]) {
|
events (tags: ["concert", "music"]) {
|
||||||
|
@ -30,13 +26,11 @@ def test_array_field_exact_no_match(Query):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||||
def test_array_field_exact_match(Query):
|
def test_array_field_exact_match(schema):
|
||||||
"""
|
"""
|
||||||
Test exact filter on a array field of string.
|
Test exact filter on a array field of string.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
schema = Schema(query=Query)
|
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
query {
|
query {
|
||||||
events (tags: ["movie", "music"]) {
|
events (tags: ["movie", "music"]) {
|
||||||
|
@ -56,13 +50,11 @@ def test_array_field_exact_match(Query):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||||
def test_array_field_exact_empty_list(Query):
|
def test_array_field_exact_empty_list(schema):
|
||||||
"""
|
"""
|
||||||
Test exact filter on a array field of string.
|
Test exact filter on a array field of string.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
schema = Schema(query=Query)
|
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
query {
|
query {
|
||||||
events (tags: []) {
|
events (tags: []) {
|
||||||
|
@ -82,11 +74,10 @@ def test_array_field_exact_empty_list(Query):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||||
def test_array_field_filter_schema_type(Query):
|
def test_array_field_filter_schema_type(schema):
|
||||||
"""
|
"""
|
||||||
Check that the type in the filter is an array field like on the object type.
|
Check that the type in the filter is an array field like on the object type.
|
||||||
"""
|
"""
|
||||||
schema = Schema(query=Query)
|
|
||||||
schema_str = str(schema)
|
schema_str = str(schema)
|
||||||
|
|
||||||
assert (
|
assert (
|
||||||
|
@ -112,6 +103,8 @@ def test_array_field_filter_schema_type(Query):
|
||||||
"tags_Contains": "[String!]",
|
"tags_Contains": "[String!]",
|
||||||
"tags_Overlap": "[String!]",
|
"tags_Overlap": "[String!]",
|
||||||
"tags": "[String!]",
|
"tags": "[String!]",
|
||||||
|
"tags_Len": "Int",
|
||||||
|
"tags_Len_In": "[Int]",
|
||||||
"tagsIds_Contains": "[Int!]",
|
"tagsIds_Contains": "[Int!]",
|
||||||
"tagsIds_Overlap": "[Int!]",
|
"tagsIds_Overlap": "[Int!]",
|
||||||
"tagsIds": "[Int!]",
|
"tagsIds": "[Int!]",
|
||||||
|
|
|
@ -1,18 +1,14 @@
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from graphene import Schema
|
|
||||||
|
|
||||||
from ...compat import ArrayField, MissingType
|
from ...compat import ArrayField, MissingType
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||||
def test_array_field_overlap_multiple(Query):
|
def test_array_field_overlap_multiple(schema):
|
||||||
"""
|
"""
|
||||||
Test overlap filter on a array field of string.
|
Test overlap filter on a array field of string.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
schema = Schema(query=Query)
|
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
query {
|
query {
|
||||||
events (tags_Overlap: ["concert", "music"]) {
|
events (tags_Overlap: ["concert", "music"]) {
|
||||||
|
@ -34,13 +30,11 @@ def test_array_field_overlap_multiple(Query):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||||
def test_array_field_overlap_one(Query):
|
def test_array_field_overlap_one(schema):
|
||||||
"""
|
"""
|
||||||
Test overlap filter on a array field of string.
|
Test overlap filter on a array field of string.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
schema = Schema(query=Query)
|
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
query {
|
query {
|
||||||
events (tags_Overlap: ["music"]) {
|
events (tags_Overlap: ["music"]) {
|
||||||
|
@ -61,13 +55,11 @@ def test_array_field_overlap_one(Query):
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
|
||||||
def test_array_field_overlap_empty_list(Query):
|
def test_array_field_overlap_empty_list(schema):
|
||||||
"""
|
"""
|
||||||
Test overlap filter on a array field of string.
|
Test overlap filter on a array field of string.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
schema = Schema(query=Query)
|
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
query {
|
query {
|
||||||
events (tags_Overlap: []) {
|
events (tags_Overlap: []) {
|
||||||
|
|
|
@ -2,8 +2,7 @@ import pytest
|
||||||
|
|
||||||
import graphene
|
import graphene
|
||||||
from graphene.relay import Node
|
from graphene.relay import Node
|
||||||
|
from graphene_django import DjangoConnectionField, DjangoObjectType
|
||||||
from graphene_django import DjangoObjectType, DjangoConnectionField
|
|
||||||
from graphene_django.tests.models import Article, Reporter
|
from graphene_django.tests.models import Article, Reporter
|
||||||
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
||||||
|
|
||||||
|
|
|
@ -19,8 +19,8 @@ if DJANGO_FILTER_INSTALLED:
|
||||||
from django_filters import FilterSet, NumberFilter, OrderingFilter
|
from django_filters import FilterSet, NumberFilter, OrderingFilter
|
||||||
|
|
||||||
from graphene_django.filter import (
|
from graphene_django.filter import (
|
||||||
GlobalIDFilter,
|
|
||||||
DjangoFilterConnectionField,
|
DjangoFilterConnectionField,
|
||||||
|
GlobalIDFilter,
|
||||||
GlobalIDMultipleChoiceFilter,
|
GlobalIDMultipleChoiceFilter,
|
||||||
)
|
)
|
||||||
from graphene_django.filter.tests.filters import (
|
from graphene_django.filter.tests.filters import (
|
||||||
|
@ -67,7 +67,7 @@ def assert_arguments(field, *arguments):
|
||||||
actual = [name for name in args if name not in ignore and not name.startswith("_")]
|
actual = [name for name in args if name not in ignore and not name.startswith("_")]
|
||||||
assert set(arguments) == set(
|
assert set(arguments) == set(
|
||||||
actual
|
actual
|
||||||
), "Expected arguments ({}) did not match actual ({})".format(arguments, actual)
|
), f"Expected arguments ({arguments}) did not match actual ({actual})"
|
||||||
|
|
||||||
|
|
||||||
def assert_orderable(field):
|
def assert_orderable(field):
|
||||||
|
@ -141,7 +141,7 @@ def test_filter_shortcut_filterset_context():
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def qs(self):
|
def qs(self):
|
||||||
qs = super(ArticleContextFilter, self).qs
|
qs = super().qs
|
||||||
return qs.filter(reporter=self.request.reporter)
|
return qs.filter(reporter=self.request.reporter)
|
||||||
|
|
||||||
class Query(ObjectType):
|
class Query(ObjectType):
|
||||||
|
@ -166,7 +166,7 @@ def test_filter_shortcut_filterset_context():
|
||||||
editor=r2,
|
editor=r2,
|
||||||
)
|
)
|
||||||
|
|
||||||
class context(object):
|
class context:
|
||||||
reporter = r2
|
reporter = r2
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
|
@ -222,7 +222,7 @@ def test_filter_filterset_information_on_meta_related():
|
||||||
reporter = Field(ReporterFilterNode)
|
reporter = Field(ReporterFilterNode)
|
||||||
article = Field(ArticleFilterNode)
|
article = Field(ArticleFilterNode)
|
||||||
|
|
||||||
schema = Schema(query=Query)
|
Schema(query=Query)
|
||||||
articles_field = ReporterFilterNode._meta.fields["articles"].get_type()
|
articles_field = ReporterFilterNode._meta.fields["articles"].get_type()
|
||||||
assert_arguments(articles_field, "headline", "reporter")
|
assert_arguments(articles_field, "headline", "reporter")
|
||||||
assert_not_orderable(articles_field)
|
assert_not_orderable(articles_field)
|
||||||
|
@ -294,7 +294,7 @@ def test_filter_filterset_class_information_on_meta_related():
|
||||||
reporter = Field(ReporterFilterNode)
|
reporter = Field(ReporterFilterNode)
|
||||||
article = Field(ArticleFilterNode)
|
article = Field(ArticleFilterNode)
|
||||||
|
|
||||||
schema = Schema(query=Query)
|
Schema(query=Query)
|
||||||
articles_field = ReporterFilterNode._meta.fields["articles"].get_type()
|
articles_field = ReporterFilterNode._meta.fields["articles"].get_type()
|
||||||
assert_arguments(articles_field, "headline", "reporter")
|
assert_arguments(articles_field, "headline", "reporter")
|
||||||
assert_not_orderable(articles_field)
|
assert_not_orderable(articles_field)
|
||||||
|
@ -789,7 +789,7 @@ def test_order_by():
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
query NodeFilteringQuery {
|
query NodeFilteringQuery {
|
||||||
allReporters(orderBy: "-firtsnaMe") {
|
allReporters(orderBy: "-firstname") {
|
||||||
edges {
|
edges {
|
||||||
node {
|
node {
|
||||||
firstName
|
firstName
|
||||||
|
@ -802,7 +802,7 @@ def test_order_by():
|
||||||
assert result.errors
|
assert result.errors
|
||||||
|
|
||||||
|
|
||||||
def test_order_by_is_perserved():
|
def test_order_by_is_preserved():
|
||||||
class ReporterType(DjangoObjectType):
|
class ReporterType(DjangoObjectType):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Reporter
|
model = Reporter
|
||||||
|
@ -1186,7 +1186,7 @@ def test_filter_filterset_based_on_mixin():
|
||||||
first_name="Adam", last_name="Doe", email="adam@doe.com"
|
first_name="Adam", last_name="Doe", email="adam@doe.com"
|
||||||
)
|
)
|
||||||
|
|
||||||
article_2 = Article.objects.create(
|
Article.objects.create(
|
||||||
headline="Good Bye",
|
headline="Good Bye",
|
||||||
reporter=reporter_2,
|
reporter=reporter_2,
|
||||||
editor=reporter_2,
|
editor=reporter_2,
|
||||||
|
|
|
@ -1,14 +1,16 @@
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from django_filters import (
|
||||||
|
FilterSet,
|
||||||
|
rest_framework as filters,
|
||||||
|
)
|
||||||
|
|
||||||
from django_filters import FilterSet
|
|
||||||
from django_filters import rest_framework as filters
|
|
||||||
from graphene import ObjectType, Schema
|
from graphene import ObjectType, Schema
|
||||||
from graphene.relay import Node
|
from graphene.relay import Node
|
||||||
from graphene_django import DjangoObjectType
|
from graphene_django import DjangoObjectType
|
||||||
from graphene_django.tests.models import Pet, Person, Reporter, Article, Film
|
|
||||||
from graphene_django.filter.tests.filters import ArticleFilter
|
from graphene_django.filter.tests.filters import ArticleFilter
|
||||||
|
from graphene_django.tests.models import Article, Film, Person, Pet, Reporter
|
||||||
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
||||||
|
|
||||||
pytestmark = []
|
pytestmark = []
|
||||||
|
@ -348,23 +350,20 @@ def test_fk_id_in_filter(query):
|
||||||
|
|
||||||
schema = Schema(query=query)
|
schema = Schema(query=query)
|
||||||
|
|
||||||
query = """
|
query = f"""
|
||||||
query {
|
query {{
|
||||||
articles (reporter_In: [%s, %s]) {
|
articles (reporter_In: [{john_doe.id}, {jean_bon.id}]) {{
|
||||||
edges {
|
edges {{
|
||||||
node {
|
node {{
|
||||||
headline
|
headline
|
||||||
reporter {
|
reporter {{
|
||||||
lastName
|
lastName
|
||||||
}
|
}}
|
||||||
}
|
}}
|
||||||
}
|
}}
|
||||||
}
|
}}
|
||||||
}
|
}}
|
||||||
""" % (
|
"""
|
||||||
john_doe.id,
|
|
||||||
jean_bon.id,
|
|
||||||
)
|
|
||||||
result = schema.execute(query)
|
result = schema.execute(query)
|
||||||
assert not result.errors
|
assert not result.errors
|
||||||
assert result.data["articles"]["edges"] == [
|
assert result.data["articles"]["edges"] == [
|
||||||
|
|
|
@ -1,8 +1,7 @@
|
||||||
import json
|
import json
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from django_filters import FilterSet
|
|
||||||
from django_filters import rest_framework as filters
|
|
||||||
from graphene import ObjectType, Schema
|
from graphene import ObjectType, Schema
|
||||||
from graphene.relay import Node
|
from graphene.relay import Node
|
||||||
from graphene_django import DjangoObjectType
|
from graphene_django import DjangoObjectType
|
||||||
|
|
|
@ -1,10 +1,12 @@
|
||||||
import pytest
|
import operator
|
||||||
|
from functools import reduce
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.db.models import Q
|
||||||
from django_filters import FilterSet
|
from django_filters import FilterSet
|
||||||
|
|
||||||
import graphene
|
import graphene
|
||||||
from graphene.relay import Node
|
from graphene.relay import Node
|
||||||
|
|
||||||
from graphene_django import DjangoObjectType
|
from graphene_django import DjangoObjectType
|
||||||
from graphene_django.tests.models import Article, Reporter
|
from graphene_django.tests.models import Article, Reporter
|
||||||
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
from graphene_django.utils import DJANGO_FILTER_INSTALLED
|
||||||
|
@ -14,8 +16,8 @@ pytestmark = []
|
||||||
if DJANGO_FILTER_INSTALLED:
|
if DJANGO_FILTER_INSTALLED:
|
||||||
from graphene_django.filter import (
|
from graphene_django.filter import (
|
||||||
DjangoFilterConnectionField,
|
DjangoFilterConnectionField,
|
||||||
TypedFilter,
|
|
||||||
ListFilter,
|
ListFilter,
|
||||||
|
TypedFilter,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
pytestmark.append(
|
pytestmark.append(
|
||||||
|
@ -46,6 +48,10 @@ def schema():
|
||||||
only_first = TypedFilter(
|
only_first = TypedFilter(
|
||||||
input_type=graphene.Boolean, method="only_first_filter"
|
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):
|
def first_n_filter(self, queryset, _name, value):
|
||||||
return queryset[:value]
|
return queryset[:value]
|
||||||
|
@ -56,6 +62,13 @@ def schema():
|
||||||
else:
|
else:
|
||||||
return queryset
|
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 ArticleType(DjangoObjectType):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Article
|
model = Article
|
||||||
|
@ -89,6 +102,7 @@ def test_typed_filter_schema(schema):
|
||||||
"lang_InStr": "[String]",
|
"lang_InStr": "[String]",
|
||||||
"firstN": "Int",
|
"firstN": "Int",
|
||||||
"onlyFirst": "Boolean",
|
"onlyFirst": "Boolean",
|
||||||
|
"headlineSearch": "[String]",
|
||||||
}
|
}
|
||||||
|
|
||||||
all_articles_filters = (
|
all_articles_filters = (
|
||||||
|
@ -98,7 +112,7 @@ def test_typed_filter_schema(schema):
|
||||||
)
|
)
|
||||||
|
|
||||||
for filter_field, gql_type in filters.items():
|
for filter_field, gql_type in filters.items():
|
||||||
assert "{}: {}".format(filter_field, gql_type) in all_articles_filters
|
assert f"{filter_field}: {gql_type}" in all_articles_filters
|
||||||
|
|
||||||
|
|
||||||
def test_typed_filters_work(schema):
|
def test_typed_filters_work(schema):
|
||||||
|
@ -106,24 +120,7 @@ def test_typed_filters_work(schema):
|
||||||
Article.objects.create(headline="A", reporter=reporter, editor=reporter, lang="es")
|
Article.objects.create(headline="A", reporter=reporter, editor=reporter, lang="es")
|
||||||
Article.objects.create(headline="B", 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="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": "B"}},
|
|
||||||
]
|
|
||||||
|
|
||||||
query = 'query { articles (lang_InStr: ["es"]) { edges { node { headline } } } }'
|
|
||||||
|
|
||||||
result = schema.execute(query)
|
|
||||||
assert not result.errors
|
|
||||||
assert result.data["articles"]["edges"] == [
|
|
||||||
{"node": {"headline": "A"}},
|
|
||||||
{"node": {"headline": "B"}},
|
|
||||||
]
|
|
||||||
|
|
||||||
query = 'query { articles (lang_Contains: "n") { edges { node { headline } } } }'
|
query = 'query { articles (lang_Contains: "n") { edges { node { headline } } } }'
|
||||||
|
|
||||||
|
@ -139,7 +136,7 @@ def test_typed_filters_work(schema):
|
||||||
assert not result.errors
|
assert not result.errors
|
||||||
assert result.data["articles"]["edges"] == [
|
assert result.data["articles"]["edges"] == [
|
||||||
{"node": {"headline": "A"}},
|
{"node": {"headline": "A"}},
|
||||||
{"node": {"headline": "B"}},
|
{"node": {"headline": "AB"}},
|
||||||
]
|
]
|
||||||
|
|
||||||
query = "query { articles (onlyFirst: true) { edges { node { headline } } } }"
|
query = "query { articles (onlyFirst: true) { edges { node { headline } } } }"
|
||||||
|
@ -149,3 +146,86 @@ def test_typed_filters_work(schema):
|
||||||
assert result.data["articles"]["edges"] == [
|
assert result.data["articles"]["edges"] == [
|
||||||
{"node": {"headline": "A"}},
|
{"node": {"headline": "A"}},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_filters_work(schema):
|
||||||
|
reporter = Reporter.objects.create(first_name="John", last_name="Doe", email="")
|
||||||
|
Article.objects.create(headline="A", reporter=reporter, editor=reporter, lang="es")
|
||||||
|
Article.objects.create(headline="B", reporter=reporter, editor=reporter, lang="es")
|
||||||
|
Article.objects.create(headline="C", reporter=reporter, editor=reporter, lang="en")
|
||||||
|
Article.objects.create(headline="AB", reporter=reporter, editor=reporter, lang="es")
|
||||||
|
|
||||||
|
query = "query { articles (lang_In: [ES]) { edges { node { headline } } } }"
|
||||||
|
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["articles"]["edges"] == [
|
||||||
|
{"node": {"headline": "A"}},
|
||||||
|
{"node": {"headline": "AB"}},
|
||||||
|
{"node": {"headline": "B"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
query = 'query { articles (lang_InStr: ["es"]) { edges { node { headline } } } }'
|
||||||
|
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["articles"]["edges"] == [
|
||||||
|
{"node": {"headline": "A"}},
|
||||||
|
{"node": {"headline": "AB"}},
|
||||||
|
{"node": {"headline": "B"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
query = "query { articles (lang_InStr: []) { edges { node { headline } } } }"
|
||||||
|
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["articles"]["edges"] == []
|
||||||
|
|
||||||
|
query = "query { articles (lang_InStr: null) { edges { node { headline } } } }"
|
||||||
|
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["articles"]["edges"] == [
|
||||||
|
{"node": {"headline": "A"}},
|
||||||
|
{"node": {"headline": "AB"}},
|
||||||
|
{"node": {"headline": "B"}},
|
||||||
|
{"node": {"headline": "C"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
query = 'query { articles (headlineSearch: ["a", "B"]) { edges { node { headline } } } }'
|
||||||
|
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["articles"]["edges"] == [
|
||||||
|
{"node": {"headline": "A"}},
|
||||||
|
{"node": {"headline": "AB"}},
|
||||||
|
{"node": {"headline": "B"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
query = "query { articles (headlineSearch: []) { edges { node { headline } } } }"
|
||||||
|
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["articles"]["edges"] == []
|
||||||
|
|
||||||
|
query = "query { articles (headlineSearch: null) { edges { node { headline } } } }"
|
||||||
|
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["articles"]["edges"] == [
|
||||||
|
{"node": {"headline": "A"}},
|
||||||
|
{"node": {"headline": "AB"}},
|
||||||
|
{"node": {"headline": "B"}},
|
||||||
|
{"node": {"headline": "C"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
query = 'query { articles (headlineSearch: [""]) { edges { node { headline } } } }'
|
||||||
|
|
||||||
|
result = schema.execute(query)
|
||||||
|
assert not result.errors
|
||||||
|
assert result.data["articles"]["edges"] == [
|
||||||
|
{"node": {"headline": "A"}},
|
||||||
|
{"node": {"headline": "AB"}},
|
||||||
|
{"node": {"headline": "B"}},
|
||||||
|
{"node": {"headline": "C"}},
|
||||||
|
]
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
import graphene
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django_filters.utils import get_model_field, get_field_parts
|
from django_filters.utils import get_model_field
|
||||||
from django_filters.filters import Filter, BaseCSVFilter
|
|
||||||
from .filters import ArrayFilter, ListFilter, RangeFilter, TypedFilter
|
import graphene
|
||||||
from .filterset import custom_filterset_factory, setup_filterset
|
|
||||||
from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField
|
from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField
|
||||||
|
from .filters import ListFilter, RangeFilter, TypedFilter
|
||||||
|
from .filterset import custom_filterset_factory, setup_filterset
|
||||||
|
|
||||||
|
|
||||||
def get_field_type(registry, model, field_name):
|
def get_field_type(registry, model, field_name):
|
||||||
|
@ -42,7 +43,7 @@ def get_filtering_args_from_filterset(filterset_class, type):
|
||||||
isinstance(filter_field, TypedFilter)
|
isinstance(filter_field, TypedFilter)
|
||||||
and filter_field.input_type is not None
|
and filter_field.input_type is not None
|
||||||
):
|
):
|
||||||
# First check if the filter input type has been explicitely given
|
# First check if the filter input type has been explicitly given
|
||||||
field_type = filter_field.input_type
|
field_type = filter_field.input_type
|
||||||
else:
|
else:
|
||||||
if name not in filterset_class.declared_filters or isinstance(
|
if name not in filterset_class.declared_filters or isinstance(
|
||||||
|
@ -50,7 +51,7 @@ def get_filtering_args_from_filterset(filterset_class, type):
|
||||||
):
|
):
|
||||||
# Get the filter field for filters that are no explicitly declared.
|
# Get the filter field for filters that are no explicitly declared.
|
||||||
if filter_type == "isnull":
|
if filter_type == "isnull":
|
||||||
field = graphene.Boolean(required=required)
|
field_type = graphene.Boolean
|
||||||
else:
|
else:
|
||||||
model_field = get_model_field(model, filter_field.field_name)
|
model_field = get_model_field(model, filter_field.field_name)
|
||||||
|
|
||||||
|
@ -144,7 +145,7 @@ def replace_csv_filters(filterset_class):
|
||||||
label=filter_field.label,
|
label=filter_field.label,
|
||||||
method=filter_field.method,
|
method=filter_field.method,
|
||||||
exclude=filter_field.exclude,
|
exclude=filter_field.exclude,
|
||||||
**filter_field.extra
|
**filter_field.extra,
|
||||||
)
|
)
|
||||||
elif filter_type == "range":
|
elif filter_type == "range":
|
||||||
filterset_class.base_filters[name] = RangeFilter(
|
filterset_class.base_filters[name] = RangeFilter(
|
||||||
|
@ -153,5 +154,5 @@ def replace_csv_filters(filterset_class):
|
||||||
label=filter_field.label,
|
label=filter_field.label,
|
||||||
method=filter_field.method,
|
method=filter_field.method,
|
||||||
exclude=filter_field.exclude,
|
exclude=filter_field.exclude,
|
||||||
**filter_field.extra
|
**filter_field.extra,
|
||||||
)
|
)
|
||||||
|
|
|
@ -5,15 +5,15 @@ from django.core.exceptions import ImproperlyConfigured
|
||||||
|
|
||||||
from graphene import (
|
from graphene import (
|
||||||
ID,
|
ID,
|
||||||
|
UUID,
|
||||||
Boolean,
|
Boolean,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
Decimal,
|
Decimal,
|
||||||
Float,
|
Float,
|
||||||
Int,
|
Int,
|
||||||
List,
|
List,
|
||||||
String,
|
String,
|
||||||
UUID,
|
|
||||||
Date,
|
|
||||||
DateTime,
|
|
||||||
Time,
|
Time,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -27,8 +27,8 @@ def get_form_field_description(field):
|
||||||
@singledispatch
|
@singledispatch
|
||||||
def convert_form_field(field):
|
def convert_form_field(field):
|
||||||
raise ImproperlyConfigured(
|
raise ImproperlyConfigured(
|
||||||
"Don't know how to convert the Django form field %s (%s) "
|
f"Don't know how to convert the Django form field {field} ({field.__class__}) "
|
||||||
"to Graphene type" % (field, field.__class__)
|
"to Graphene type"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,6 @@ import binascii
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.forms import CharField, Field, MultipleChoiceField
|
from django.forms import CharField, Field, MultipleChoiceField
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from graphql_relay import from_global_id
|
from graphql_relay import from_global_id
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -23,8 +23,7 @@ def fields_for_form(form, only_fields, exclude_fields):
|
||||||
for name, field in form.fields.items():
|
for name, field in form.fields.items():
|
||||||
is_not_in_only = only_fields and name not in only_fields
|
is_not_in_only = only_fields and name not in only_fields
|
||||||
is_excluded = (
|
is_excluded = (
|
||||||
name
|
name in exclude_fields # or
|
||||||
in exclude_fields # or
|
|
||||||
# name in already_created_fields
|
# name in already_created_fields
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -82,7 +81,6 @@ class DjangoFormMutation(BaseDjangoFormMutation):
|
||||||
def __init_subclass_with_meta__(
|
def __init_subclass_with_meta__(
|
||||||
cls, form_class=None, only_fields=(), exclude_fields=(), **options
|
cls, form_class=None, only_fields=(), exclude_fields=(), **options
|
||||||
):
|
):
|
||||||
|
|
||||||
if not form_class:
|
if not form_class:
|
||||||
raise Exception("form_class is required for DjangoFormMutation")
|
raise Exception("form_class is required for DjangoFormMutation")
|
||||||
|
|
||||||
|
@ -95,7 +93,7 @@ class DjangoFormMutation(BaseDjangoFormMutation):
|
||||||
_meta.fields = yank_fields_from_attrs(output_fields, _as=Field)
|
_meta.fields = yank_fields_from_attrs(output_fields, _as=Field)
|
||||||
|
|
||||||
input_fields = yank_fields_from_attrs(input_fields, _as=InputField)
|
input_fields = yank_fields_from_attrs(input_fields, _as=InputField)
|
||||||
super(DjangoFormMutation, cls).__init_subclass_with_meta__(
|
super().__init_subclass_with_meta__(
|
||||||
_meta=_meta, input_fields=input_fields, **options
|
_meta=_meta, input_fields=input_fields, **options
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -127,9 +125,8 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
|
||||||
return_field_name=None,
|
return_field_name=None,
|
||||||
only_fields=(),
|
only_fields=(),
|
||||||
exclude_fields=(),
|
exclude_fields=(),
|
||||||
**options
|
**options,
|
||||||
):
|
):
|
||||||
|
|
||||||
if not form_class:
|
if not form_class:
|
||||||
raise Exception("form_class is required for DjangoModelFormMutation")
|
raise Exception("form_class is required for DjangoModelFormMutation")
|
||||||
|
|
||||||
|
@ -147,7 +144,7 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
|
||||||
registry = get_global_registry()
|
registry = get_global_registry()
|
||||||
model_type = registry.get_type_for_model(model)
|
model_type = registry.get_type_for_model(model)
|
||||||
if not model_type:
|
if not model_type:
|
||||||
raise Exception("No type registered for model: {}".format(model.__name__))
|
raise Exception(f"No type registered for model: {model.__name__}")
|
||||||
|
|
||||||
if not return_field_name:
|
if not return_field_name:
|
||||||
model_name = model.__name__
|
model_name = model.__name__
|
||||||
|
@ -163,7 +160,7 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
|
||||||
_meta.fields = yank_fields_from_attrs(output_fields, _as=Field)
|
_meta.fields = yank_fields_from_attrs(output_fields, _as=Field)
|
||||||
|
|
||||||
input_fields = yank_fields_from_attrs(input_fields, _as=InputField)
|
input_fields = yank_fields_from_attrs(input_fields, _as=InputField)
|
||||||
super(DjangoModelFormMutation, cls).__init_subclass_with_meta__(
|
super().__init_subclass_with_meta__(
|
||||||
_meta=_meta, input_fields=input_fields, **options
|
_meta=_meta, input_fields=input_fields, **options
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -1,31 +1,34 @@
|
||||||
from django import forms
|
from django import VERSION as DJANGO_VERSION, forms
|
||||||
from py.test import raises
|
from pytest import raises
|
||||||
|
|
||||||
import graphene
|
|
||||||
from graphene import (
|
from graphene import (
|
||||||
String,
|
|
||||||
Int,
|
|
||||||
Boolean,
|
|
||||||
Decimal,
|
|
||||||
Float,
|
|
||||||
ID,
|
ID,
|
||||||
UUID,
|
UUID,
|
||||||
|
Boolean,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
Decimal,
|
||||||
|
Float,
|
||||||
|
Int,
|
||||||
List,
|
List,
|
||||||
NonNull,
|
NonNull,
|
||||||
DateTime,
|
String,
|
||||||
Date,
|
|
||||||
Time,
|
Time,
|
||||||
)
|
)
|
||||||
|
|
||||||
from ..converter import convert_form_field
|
from ..converter import convert_form_field
|
||||||
|
|
||||||
|
|
||||||
def assert_conversion(django_field, graphene_field, *args):
|
def assert_conversion(django_field, graphene_field, *args, **kwargs):
|
||||||
field = django_field(*args, help_text="Custom Help Text")
|
# Arrange
|
||||||
|
help_text = kwargs.setdefault("help_text", "Custom Help Text")
|
||||||
|
field = django_field(*args, **kwargs)
|
||||||
|
# Act
|
||||||
graphene_type = convert_form_field(field)
|
graphene_type = convert_form_field(field)
|
||||||
|
# Assert
|
||||||
assert isinstance(graphene_type, graphene_field)
|
assert isinstance(graphene_type, graphene_field)
|
||||||
field = graphene_type.Field()
|
field = graphene_type.Field()
|
||||||
assert field.description == "Custom Help Text"
|
assert field.description == help_text
|
||||||
return field
|
return field
|
||||||
|
|
||||||
|
|
||||||
|
@ -60,7 +63,12 @@ def test_should_slug_convert_string():
|
||||||
|
|
||||||
|
|
||||||
def test_should_url_convert_string():
|
def test_should_url_convert_string():
|
||||||
assert_conversion(forms.URLField, String)
|
kwargs = {}
|
||||||
|
if DJANGO_VERSION >= (5, 0):
|
||||||
|
# silence RemovedInDjango60Warning
|
||||||
|
kwargs["assume_scheme"] = "https"
|
||||||
|
|
||||||
|
assert_conversion(forms.URLField, String, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
def test_should_choice_convert_string():
|
def test_should_choice_convert_string():
|
||||||
|
@ -76,8 +84,7 @@ def test_should_regex_convert_string():
|
||||||
|
|
||||||
|
|
||||||
def test_should_uuid_convert_string():
|
def test_should_uuid_convert_string():
|
||||||
if hasattr(forms, "UUIDField"):
|
assert_conversion(forms.UUIDField, UUID)
|
||||||
assert_conversion(forms.UUIDField, UUID)
|
|
||||||
|
|
||||||
|
|
||||||
def test_should_integer_convert_int():
|
def test_should_integer_convert_int():
|
||||||
|
|
333
graphene_django/forms/tests/test_djangoinputobject.py
Normal file
333
graphene_django/forms/tests/test_djangoinputobject.py
Normal file
|
@ -0,0 +1,333 @@
|
||||||
|
from django import forms
|
||||||
|
from pytest import raises
|
||||||
|
|
||||||
|
import graphene
|
||||||
|
from graphene_django import DjangoObjectType
|
||||||
|
|
||||||
|
from ...tests.models import CHOICES, Film, Reporter
|
||||||
|
from ..types import DjangoFormInputObjectType
|
||||||
|
|
||||||
|
# Reporter a_choice CHOICES = ((1, "this"), (2, _("that")))
|
||||||
|
THIS = CHOICES[0][0]
|
||||||
|
THIS_ON_CLIENT_CONVERTED = "A_1"
|
||||||
|
|
||||||
|
# Film genre choices=[("do", "Documentary"), ("ac", "Action"), ("ot", "Other")],
|
||||||
|
DOCUMENTARY = "do"
|
||||||
|
DOCUMENTARY_ON_CLIENT_CONVERTED = "DO"
|
||||||
|
|
||||||
|
|
||||||
|
class FilmForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Film
|
||||||
|
exclude = ()
|
||||||
|
|
||||||
|
|
||||||
|
class ReporterType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Reporter
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
|
||||||
|
class ReporterForm(forms.ModelForm):
|
||||||
|
class Meta:
|
||||||
|
model = Reporter
|
||||||
|
exclude = ("pets", "email", "fans")
|
||||||
|
|
||||||
|
|
||||||
|
class MyForm(forms.Form):
|
||||||
|
text_field = forms.CharField()
|
||||||
|
int_field = forms.IntegerField()
|
||||||
|
|
||||||
|
|
||||||
|
def test_needs_form_class():
|
||||||
|
with raises(Exception) as exc:
|
||||||
|
|
||||||
|
class MyInputType(DjangoFormInputObjectType):
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert exc.value.args[0] == "form_class is required for DjangoFormInputObjectType"
|
||||||
|
|
||||||
|
|
||||||
|
def test_type_from_modelform_has_input_fields():
|
||||||
|
class ReporterInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = ReporterForm
|
||||||
|
only_fields = ("first_name", "last_name", "a_choice")
|
||||||
|
|
||||||
|
fields = ["first_name", "last_name", "a_choice", "id"]
|
||||||
|
assert all(f in ReporterInputType._meta.fields for f in fields)
|
||||||
|
|
||||||
|
|
||||||
|
def test_type_from_form_has_input_fields():
|
||||||
|
class MyFormInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = MyForm
|
||||||
|
|
||||||
|
fields = ["text_field", "int_field", "id"]
|
||||||
|
assert all(f in MyFormInputType._meta.fields for f in fields)
|
||||||
|
|
||||||
|
|
||||||
|
def test_type_custom_id_field():
|
||||||
|
class MyFormInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = MyForm
|
||||||
|
add_id_field_name = "pk"
|
||||||
|
|
||||||
|
fields = ["text_field", "int_field", "pk"]
|
||||||
|
assert all(f in MyFormInputType._meta.fields for f in fields)
|
||||||
|
assert MyFormInputType._meta.fields["pk"].type is graphene.ID
|
||||||
|
|
||||||
|
|
||||||
|
def test_type_custom_id_field_type():
|
||||||
|
class MyFormInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = MyForm
|
||||||
|
add_id_field_name = "pk"
|
||||||
|
add_id_field_type = graphene.String(required=False)
|
||||||
|
|
||||||
|
fields = ["text_field", "int_field", "pk"]
|
||||||
|
assert all(f in MyFormInputType._meta.fields for f in fields)
|
||||||
|
assert MyFormInputType._meta.fields["pk"].type is graphene.String
|
||||||
|
|
||||||
|
|
||||||
|
class MockQuery(graphene.ObjectType):
|
||||||
|
a = graphene.String()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mutation_with_form_djangoforminputtype():
|
||||||
|
class MyFormInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = MyForm
|
||||||
|
|
||||||
|
class MyFormMutation(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
form_data = MyFormInputType(required=True)
|
||||||
|
|
||||||
|
result = graphene.Boolean()
|
||||||
|
|
||||||
|
def mutate(_root, _info, form_data):
|
||||||
|
form = MyForm(data=form_data)
|
||||||
|
if form.is_valid():
|
||||||
|
result = form.cleaned_data == {
|
||||||
|
"text_field": "text",
|
||||||
|
"int_field": 777,
|
||||||
|
}
|
||||||
|
return MyFormMutation(result=result)
|
||||||
|
return MyFormMutation(result=False)
|
||||||
|
|
||||||
|
class Mutation(graphene.ObjectType):
|
||||||
|
myForm_mutation = MyFormMutation.Field()
|
||||||
|
|
||||||
|
schema = graphene.Schema(query=MockQuery, mutation=Mutation)
|
||||||
|
|
||||||
|
result = schema.execute(
|
||||||
|
""" mutation MyFormMutation($formData: MyFormInputType!) {
|
||||||
|
myFormMutation(formData: $formData) {
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
variable_values={"formData": {"textField": "text", "intField": 777}},
|
||||||
|
)
|
||||||
|
assert result.errors is None
|
||||||
|
assert result.data == {"myFormMutation": {"result": True}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_mutation_with_modelform_djangoforminputtype():
|
||||||
|
class ReporterInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = ReporterForm
|
||||||
|
object_type = ReporterType
|
||||||
|
only_fields = ("first_name", "last_name", "a_choice")
|
||||||
|
|
||||||
|
class ReporterMutation(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
reporter_data = ReporterInputType(required=True)
|
||||||
|
|
||||||
|
result = graphene.Field(ReporterType)
|
||||||
|
|
||||||
|
def mutate(_root, _info, reporter_data):
|
||||||
|
reporter = Reporter.objects.get(pk=reporter_data.id)
|
||||||
|
form = ReporterForm(data=reporter_data, instance=reporter)
|
||||||
|
if form.is_valid():
|
||||||
|
reporter = form.save()
|
||||||
|
return ReporterMutation(result=reporter)
|
||||||
|
|
||||||
|
return ReporterMutation(result=None)
|
||||||
|
|
||||||
|
class Mutation(graphene.ObjectType):
|
||||||
|
report_mutation = ReporterMutation.Field()
|
||||||
|
|
||||||
|
schema = graphene.Schema(query=MockQuery, mutation=Mutation)
|
||||||
|
|
||||||
|
reporter = Reporter.objects.create(
|
||||||
|
first_name="Bob", last_name="Roberts", a_choice=THIS
|
||||||
|
)
|
||||||
|
|
||||||
|
result = schema.execute(
|
||||||
|
""" mutation ReportMutation($reporterData: ReporterInputType!) {
|
||||||
|
reportMutation(reporterData: $reporterData) {
|
||||||
|
result {
|
||||||
|
id,
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
aChoice
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
variable_values={
|
||||||
|
"reporterData": {
|
||||||
|
"id": reporter.pk,
|
||||||
|
"firstName": "Dave",
|
||||||
|
"lastName": "Smith",
|
||||||
|
"aChoice": THIS_ON_CLIENT_CONVERTED,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert result.errors is None
|
||||||
|
assert result.data["reportMutation"]["result"] == {
|
||||||
|
"id": "1",
|
||||||
|
"firstName": "Dave",
|
||||||
|
"lastName": "Smith",
|
||||||
|
"aChoice": THIS_ON_CLIENT_CONVERTED,
|
||||||
|
}
|
||||||
|
assert Reporter.objects.count() == 1
|
||||||
|
reporter.refresh_from_db()
|
||||||
|
assert reporter.first_name == "Dave"
|
||||||
|
|
||||||
|
|
||||||
|
def reporter_enum_convert_mutation_result(
|
||||||
|
ReporterInputType, choice_val_on_client=THIS_ON_CLIENT_CONVERTED
|
||||||
|
):
|
||||||
|
class ReporterMutation(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
reporter = ReporterInputType(required=True)
|
||||||
|
|
||||||
|
result_str = graphene.String()
|
||||||
|
result_int = graphene.Int()
|
||||||
|
|
||||||
|
def mutate(_root, _info, reporter):
|
||||||
|
if isinstance(reporter.a_choice, int) or reporter.a_choice.isdigit():
|
||||||
|
return ReporterMutation(result_int=reporter.a_choice, result_str=None)
|
||||||
|
return ReporterMutation(result_int=None, result_str=reporter.a_choice)
|
||||||
|
|
||||||
|
class Mutation(graphene.ObjectType):
|
||||||
|
report_mutation = ReporterMutation.Field()
|
||||||
|
|
||||||
|
schema = graphene.Schema(query=MockQuery, mutation=Mutation)
|
||||||
|
|
||||||
|
return schema.execute(
|
||||||
|
""" mutation ReportMutation($reporter: ReporterInputType!) {
|
||||||
|
reportMutation(reporter: $reporter) {
|
||||||
|
resultStr,
|
||||||
|
resultInt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
variable_values={"reporter": {"aChoice": choice_val_on_client}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_enum_not_converted():
|
||||||
|
class ReporterInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = ReporterForm
|
||||||
|
only_fields = ("a_choice",)
|
||||||
|
|
||||||
|
result = reporter_enum_convert_mutation_result(ReporterInputType)
|
||||||
|
assert result.errors is None
|
||||||
|
assert result.data["reportMutation"]["resultStr"] == THIS_ON_CLIENT_CONVERTED
|
||||||
|
assert result.data["reportMutation"]["resultInt"] is None
|
||||||
|
assert ReporterInputType._meta.fields["a_choice"].type is graphene.String
|
||||||
|
|
||||||
|
|
||||||
|
def test_enum_is_converted_to_original():
|
||||||
|
class ReporterInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = ReporterForm
|
||||||
|
object_type = ReporterType
|
||||||
|
only_fields = ("a_choice",)
|
||||||
|
|
||||||
|
result = reporter_enum_convert_mutation_result(ReporterInputType)
|
||||||
|
assert result.errors is None
|
||||||
|
assert result.data["reportMutation"]["resultInt"] == THIS
|
||||||
|
assert result.data["reportMutation"]["resultStr"] is None
|
||||||
|
assert (
|
||||||
|
ReporterInputType._meta.fields["a_choice"].type.__name__
|
||||||
|
== "AChoiceEnumBackConvString"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_convert_choices_to_enum_is_false_and_field_type_as_in_model():
|
||||||
|
class ReporterTypeNotConvertChoices(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Reporter
|
||||||
|
convert_choices_to_enum = False
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
class ReporterInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = ReporterForm
|
||||||
|
object_type = ReporterTypeNotConvertChoices
|
||||||
|
only_fields = ("a_choice",)
|
||||||
|
|
||||||
|
result = reporter_enum_convert_mutation_result(ReporterInputType, THIS)
|
||||||
|
assert result.errors is None
|
||||||
|
assert result.data["reportMutation"]["resultInt"] == THIS
|
||||||
|
assert result.data["reportMutation"]["resultStr"] is None
|
||||||
|
assert ReporterInputType._meta.fields["a_choice"].type is graphene.Int
|
||||||
|
|
||||||
|
|
||||||
|
def enum_convert_mutation_result_film(FilmInputType):
|
||||||
|
class FilmMutation(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
film = FilmInputType(required=True)
|
||||||
|
|
||||||
|
result = graphene.String()
|
||||||
|
|
||||||
|
def mutate(_root, _info, film):
|
||||||
|
return FilmMutation(result=film.genre)
|
||||||
|
|
||||||
|
class Mutation(graphene.ObjectType):
|
||||||
|
film_mutation = FilmMutation.Field()
|
||||||
|
|
||||||
|
schema = graphene.Schema(query=MockQuery, mutation=Mutation)
|
||||||
|
|
||||||
|
return schema.execute(
|
||||||
|
""" mutation FilmMutation($film: FilmInputType!) {
|
||||||
|
filmMutation(film: $film) {
|
||||||
|
result
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""",
|
||||||
|
variable_values={"film": {"genre": DOCUMENTARY_ON_CLIENT_CONVERTED}},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_enum_not_converted_required_non_number():
|
||||||
|
class FilmInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = FilmForm
|
||||||
|
only_fields = ("genre",)
|
||||||
|
|
||||||
|
result = enum_convert_mutation_result_film(FilmInputType)
|
||||||
|
assert result.errors is None
|
||||||
|
assert result.data["filmMutation"]["result"] == DOCUMENTARY_ON_CLIENT_CONVERTED
|
||||||
|
|
||||||
|
|
||||||
|
def test_enum_is_converted_to_original_required_non_number():
|
||||||
|
class FilmType(DjangoObjectType):
|
||||||
|
class Meta:
|
||||||
|
model = Film
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
class FilmInputType(DjangoFormInputObjectType):
|
||||||
|
class Meta:
|
||||||
|
form_class = FilmForm
|
||||||
|
object_type = FilmType
|
||||||
|
only_fields = ("genre",)
|
||||||
|
|
||||||
|
result = enum_convert_mutation_result_film(FilmInputType)
|
||||||
|
assert result.errors is None
|
||||||
|
assert result.data["filmMutation"]["result"] == DOCUMENTARY
|
|
@ -1,7 +1,6 @@
|
||||||
import pytest
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from py.test import raises
|
from pytest import raises
|
||||||
|
|
||||||
from graphene import Field, ObjectType, Schema, String
|
from graphene import Field, ObjectType, Schema, String
|
||||||
from graphene_django import DjangoObjectType
|
from graphene_django import DjangoObjectType
|
||||||
|
@ -280,7 +279,7 @@ def test_model_form_mutation_mutate_invalid_form():
|
||||||
result = PetMutation.mutate_and_get_payload(None, None)
|
result = PetMutation.mutate_and_get_payload(None, None)
|
||||||
|
|
||||||
# A pet was not created
|
# A pet was not created
|
||||||
Pet.objects.count() == 0
|
assert Pet.objects.count() == 0
|
||||||
|
|
||||||
fields_w_error = [e.field for e in result.errors]
|
fields_w_error = [e.field for e in result.errors]
|
||||||
assert len(result.errors) == 2
|
assert len(result.errors) == 2
|
||||||
|
|
|
@ -1 +1,116 @@
|
||||||
from ..types import ErrorType # noqa Import ErrorType for backwards compatability
|
import graphene
|
||||||
|
from graphene import ID
|
||||||
|
from graphene.types.inputobjecttype import InputObjectType
|
||||||
|
from graphene.utils.str_converters import to_camel_case
|
||||||
|
|
||||||
|
from ..converter import EnumValueField
|
||||||
|
from ..types import ErrorType # noqa Import ErrorType for backwards compatibility
|
||||||
|
from .mutation import fields_for_form
|
||||||
|
|
||||||
|
|
||||||
|
class DjangoFormInputObjectType(InputObjectType):
|
||||||
|
@classmethod
|
||||||
|
def __init_subclass_with_meta__(
|
||||||
|
cls,
|
||||||
|
container=None,
|
||||||
|
_meta=None,
|
||||||
|
only_fields=(),
|
||||||
|
exclude_fields=(),
|
||||||
|
form_class=None,
|
||||||
|
object_type=None,
|
||||||
|
add_id_field_name=None,
|
||||||
|
add_id_field_type=None,
|
||||||
|
**options,
|
||||||
|
):
|
||||||
|
"""Retrieve fields from django form (Meta.form_class). Received
|
||||||
|
fields are set to cls (they will be converted to input fields
|
||||||
|
by InputObjectType). Type of fields with choices (converted
|
||||||
|
to enum) is set to custom scalar type (using Meta.object_type)
|
||||||
|
to dynamically convert enum values back.
|
||||||
|
|
||||||
|
class MyDjangoFormInput(DjangoFormInputObjectType):
|
||||||
|
# any other fields can be placed here and other inputobjectforms as well
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
form_class = MyDjangoModelForm
|
||||||
|
object_type = MyModelType
|
||||||
|
|
||||||
|
class SomeMutation(graphene.Mutation):
|
||||||
|
class Arguments:
|
||||||
|
data = MyDjangoFormInput(required=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def mutate(_root, _info, data):
|
||||||
|
form_inst = MyDjangoModelForm(data=data)
|
||||||
|
if form_inst.is_valid():
|
||||||
|
django_model_instance = form_inst.save(commit=False)
|
||||||
|
# ... etc ...
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not form_class:
|
||||||
|
raise Exception("form_class is required for DjangoFormInputObjectType")
|
||||||
|
|
||||||
|
form = form_class()
|
||||||
|
form_fields = fields_for_form(form, only_fields, exclude_fields)
|
||||||
|
|
||||||
|
for name, field in form_fields.items():
|
||||||
|
if (
|
||||||
|
object_type
|
||||||
|
and name in object_type._meta.fields
|
||||||
|
and isinstance(object_type._meta.fields[name], EnumValueField)
|
||||||
|
):
|
||||||
|
# Field type EnumValueField here means that field
|
||||||
|
# with choices have been converted to enum
|
||||||
|
setattr(cls, name, cls.get_enum_cnv_cls_instance(name, object_type))
|
||||||
|
elif (
|
||||||
|
object_type
|
||||||
|
and name in object_type._meta.fields
|
||||||
|
and object_type._meta.convert_choices_to_enum is False
|
||||||
|
and form.fields[name].__class__.__name__ == "TypedChoiceField"
|
||||||
|
):
|
||||||
|
# FIXME
|
||||||
|
# in case if convert_choices_to_enum is False
|
||||||
|
# form field class is converted to String but original
|
||||||
|
# model field type is needed here... (.converter.py bug?)
|
||||||
|
# This is temp workaround to get field type from ObjectType field
|
||||||
|
# TEST: test_enum_not_converted_and_field_type_as_in_model
|
||||||
|
setattr(cls, name, object_type._meta.fields[name].type())
|
||||||
|
else:
|
||||||
|
# set input field according to django form field
|
||||||
|
setattr(cls, name, field)
|
||||||
|
|
||||||
|
# explicitly adding id field (absent in django form fields)
|
||||||
|
# with name and type from Meta or 'id' with graphene.ID by default
|
||||||
|
if add_id_field_name:
|
||||||
|
setattr(cls, add_id_field_name, add_id_field_type or ID(required=False))
|
||||||
|
elif "id" not in exclude_fields:
|
||||||
|
cls.id = ID(required=False)
|
||||||
|
|
||||||
|
super().__init_subclass_with_meta__(container=container, _meta=_meta, **options)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_enum_cnv_cls_instance(field_name, object_type):
|
||||||
|
"""Saves args in context to convert enum values in
|
||||||
|
Dynamically created Scalar derived class
|
||||||
|
"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def parse_value(value):
|
||||||
|
# field_name & object_type have been saved in context (closure)
|
||||||
|
field = object_type._meta.fields[field_name]
|
||||||
|
if isinstance(field.type, graphene.NonNull):
|
||||||
|
val_before_convert = field.type._of_type[value].value
|
||||||
|
else:
|
||||||
|
val_before_convert = field.type[value].value
|
||||||
|
return graphene.String.parse_value(val_before_convert)
|
||||||
|
|
||||||
|
cls_doc = "String scalar to convert choice value back from enum to original"
|
||||||
|
scalar_type = type(
|
||||||
|
(
|
||||||
|
f"{field_name[0].upper()}{to_camel_case(field_name[1:])}"
|
||||||
|
"EnumBackConvString"
|
||||||
|
),
|
||||||
|
(graphene.String,),
|
||||||
|
{"parse_value": parse_value, "__doc__": cls_doc},
|
||||||
|
)
|
||||||
|
return scalar_type()
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import os
|
import functools
|
||||||
import importlib
|
import importlib
|
||||||
import json
|
import json
|
||||||
import functools
|
import os
|
||||||
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
from django.utils import autoreload
|
from django.utils import autoreload
|
||||||
|
|
||||||
from graphql import print_schema
|
from graphql import print_schema
|
||||||
|
|
||||||
from graphene_django.settings import graphene_settings
|
from graphene_django.settings import graphene_settings
|
||||||
|
|
||||||
|
|
||||||
|
@ -63,7 +63,7 @@ class Command(CommandArguments):
|
||||||
if out == "-" or out == "-.json":
|
if out == "-" or out == "-.json":
|
||||||
self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True))
|
self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True))
|
||||||
elif out == "-.graphql":
|
elif out == "-.graphql":
|
||||||
self.stdout.write(print_schema(schema))
|
self.stdout.write(print_schema(schema.graphql_schema))
|
||||||
else:
|
else:
|
||||||
# Determine format
|
# Determine format
|
||||||
_, file_extension = os.path.splitext(out)
|
_, file_extension = os.path.splitext(out)
|
||||||
|
@ -73,21 +73,17 @@ class Command(CommandArguments):
|
||||||
elif file_extension == ".json":
|
elif file_extension == ".json":
|
||||||
self.save_json_file(out, schema_dict, indent)
|
self.save_json_file(out, schema_dict, indent)
|
||||||
else:
|
else:
|
||||||
raise CommandError(
|
raise CommandError(f'Unrecognised file format "{file_extension}"')
|
||||||
'Unrecognised file format "{}"'.format(file_extension)
|
|
||||||
)
|
|
||||||
|
|
||||||
style = getattr(self, "style", None)
|
style = getattr(self, "style", None)
|
||||||
success = getattr(style, "SUCCESS", lambda x: x)
|
success = getattr(style, "SUCCESS", lambda x: x)
|
||||||
|
|
||||||
self.stdout.write(
|
self.stdout.write(success(f"Successfully dumped GraphQL schema to {out}"))
|
||||||
success("Successfully dumped GraphQL schema to {}".format(out))
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
options_schema = options.get("schema")
|
options_schema = options.get("schema")
|
||||||
|
|
||||||
if options_schema and type(options_schema) is str:
|
if options_schema and isinstance(options_schema, str):
|
||||||
module_str, schema_name = options_schema.rsplit(".", 1)
|
module_str, schema_name = options_schema.rsplit(".", 1)
|
||||||
mod = importlib.import_module(module_str)
|
mod = importlib.import_module(module_str)
|
||||||
schema = getattr(mod, schema_name)
|
schema = getattr(mod, schema_name)
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
class Registry(object):
|
class Registry:
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._registry = {}
|
self._registry = {}
|
||||||
self._field_registry = {}
|
self._field_registry = {}
|
||||||
|
@ -8,9 +8,7 @@ class Registry(object):
|
||||||
|
|
||||||
assert issubclass(
|
assert issubclass(
|
||||||
cls, DjangoObjectType
|
cls, DjangoObjectType
|
||||||
), 'Only DjangoObjectTypes can be registered, received "{}"'.format(
|
), f'Only DjangoObjectTypes can be registered, received "{cls.__name__}"'
|
||||||
cls.__name__
|
|
||||||
)
|
|
||||||
assert cls._meta.registry == self, "Registry for a Model have to match."
|
assert cls._meta.registry == self, "Registry for a Model have to match."
|
||||||
# assert self.get_type_for_model(cls._meta.model) == cls, (
|
# assert self.get_type_for_model(cls._meta.model) == cls, (
|
||||||
# 'Multiple DjangoObjectTypes registered for "{}"'.format(cls._meta.model)
|
# 'Multiple DjangoObjectTypes registered for "{}"'.format(cls._meta.model)
|
||||||
|
|
|
@ -14,3 +14,14 @@ class MyFakeModelWithPassword(models.Model):
|
||||||
class MyFakeModelWithDate(models.Model):
|
class MyFakeModelWithDate(models.Model):
|
||||||
cool_name = models.CharField(max_length=50)
|
cool_name = models.CharField(max_length=50)
|
||||||
last_edited = models.DateField()
|
last_edited = models.DateField()
|
||||||
|
|
||||||
|
|
||||||
|
class MyFakeModelWithChoiceField(models.Model):
|
||||||
|
class ChoiceType(models.Choices):
|
||||||
|
ASDF = "asdf"
|
||||||
|
HI = "hi"
|
||||||
|
|
||||||
|
choice_type = models.CharField(
|
||||||
|
max_length=4,
|
||||||
|
default=ChoiceType.HI.name,
|
||||||
|
)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
@ -18,6 +19,7 @@ class SerializerMutationOptions(MutationOptions):
|
||||||
model_class = None
|
model_class = None
|
||||||
model_operations = ["create", "update"]
|
model_operations = ["create", "update"]
|
||||||
serializer_class = None
|
serializer_class = None
|
||||||
|
optional_fields = ()
|
||||||
|
|
||||||
|
|
||||||
def fields_for_serializer(
|
def fields_for_serializer(
|
||||||
|
@ -27,6 +29,7 @@ def fields_for_serializer(
|
||||||
is_input=False,
|
is_input=False,
|
||||||
convert_choices_to_enum=True,
|
convert_choices_to_enum=True,
|
||||||
lookup_field=None,
|
lookup_field=None,
|
||||||
|
optional_fields=(),
|
||||||
):
|
):
|
||||||
fields = OrderedDict()
|
fields = OrderedDict()
|
||||||
for name, field in serializer.fields.items():
|
for name, field in serializer.fields.items():
|
||||||
|
@ -39,14 +42,21 @@ def fields_for_serializer(
|
||||||
field.read_only
|
field.read_only
|
||||||
and is_input
|
and is_input
|
||||||
and lookup_field != name, # don't show read_only fields in Input
|
and lookup_field != name, # don't show read_only fields in Input
|
||||||
|
isinstance(
|
||||||
|
field, serializers.HiddenField
|
||||||
|
), # don't show hidden fields in Input
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
if is_not_in_only or is_excluded:
|
if is_not_in_only or is_excluded:
|
||||||
continue
|
continue
|
||||||
|
is_optional = name in optional_fields or "__all__" in optional_fields
|
||||||
|
|
||||||
fields[name] = convert_serializer_field(
|
fields[name] = convert_serializer_field(
|
||||||
field, is_input=is_input, convert_choices_to_enum=convert_choices_to_enum
|
field,
|
||||||
|
is_input=is_input,
|
||||||
|
convert_choices_to_enum=convert_choices_to_enum,
|
||||||
|
force_optional=is_optional,
|
||||||
)
|
)
|
||||||
return fields
|
return fields
|
||||||
|
|
||||||
|
@ -70,9 +80,9 @@ class SerializerMutation(ClientIDMutation):
|
||||||
exclude_fields=(),
|
exclude_fields=(),
|
||||||
convert_choices_to_enum=True,
|
convert_choices_to_enum=True,
|
||||||
_meta=None,
|
_meta=None,
|
||||||
**options
|
optional_fields=(),
|
||||||
|
**options,
|
||||||
):
|
):
|
||||||
|
|
||||||
if not serializer_class:
|
if not serializer_class:
|
||||||
raise Exception("serializer_class is required for the SerializerMutation")
|
raise Exception("serializer_class is required for the SerializerMutation")
|
||||||
|
|
||||||
|
@ -95,6 +105,7 @@ class SerializerMutation(ClientIDMutation):
|
||||||
is_input=True,
|
is_input=True,
|
||||||
convert_choices_to_enum=convert_choices_to_enum,
|
convert_choices_to_enum=convert_choices_to_enum,
|
||||||
lookup_field=lookup_field,
|
lookup_field=lookup_field,
|
||||||
|
optional_fields=optional_fields,
|
||||||
)
|
)
|
||||||
output_fields = fields_for_serializer(
|
output_fields = fields_for_serializer(
|
||||||
serializer,
|
serializer,
|
||||||
|
@ -114,7 +125,7 @@ class SerializerMutation(ClientIDMutation):
|
||||||
_meta.fields = yank_fields_from_attrs(output_fields, _as=Field)
|
_meta.fields = yank_fields_from_attrs(output_fields, _as=Field)
|
||||||
|
|
||||||
input_fields = yank_fields_from_attrs(input_fields, _as=InputField)
|
input_fields = yank_fields_from_attrs(input_fields, _as=InputField)
|
||||||
super(SerializerMutation, cls).__init_subclass_with_meta__(
|
super().__init_subclass_with_meta__(
|
||||||
_meta=_meta, input_fields=input_fields, **options
|
_meta=_meta, input_fields=input_fields, **options
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -122,8 +133,10 @@ class SerializerMutation(ClientIDMutation):
|
||||||
def get_serializer_kwargs(cls, root, info, **input):
|
def get_serializer_kwargs(cls, root, info, **input):
|
||||||
lookup_field = cls._meta.lookup_field
|
lookup_field = cls._meta.lookup_field
|
||||||
model_class = cls._meta.model_class
|
model_class = cls._meta.model_class
|
||||||
|
|
||||||
if model_class:
|
if model_class:
|
||||||
|
for input_dict_key, maybe_enum in input.items():
|
||||||
|
if isinstance(maybe_enum, Enum):
|
||||||
|
input[input_dict_key] = maybe_enum.value
|
||||||
if "update" in cls._meta.model_operations and lookup_field in input:
|
if "update" in cls._meta.model_operations and lookup_field in input:
|
||||||
instance = get_object_or_404(
|
instance = get_object_or_404(
|
||||||
model_class, **{lookup_field: input[lookup_field]}
|
model_class, **{lookup_field: input[lookup_field]}
|
||||||
|
|
|
@ -5,20 +5,22 @@ from rest_framework import serializers
|
||||||
|
|
||||||
import graphene
|
import graphene
|
||||||
|
|
||||||
from ..registry import get_global_registry
|
|
||||||
from ..converter import convert_choices_to_named_enum_with_descriptions
|
from ..converter import convert_choices_to_named_enum_with_descriptions
|
||||||
|
from ..registry import get_global_registry
|
||||||
from .types import DictType
|
from .types import DictType
|
||||||
|
|
||||||
|
|
||||||
@singledispatch
|
@singledispatch
|
||||||
def get_graphene_type_from_serializer_field(field):
|
def get_graphene_type_from_serializer_field(field):
|
||||||
raise ImproperlyConfigured(
|
raise ImproperlyConfigured(
|
||||||
"Don't know how to convert the serializer field %s (%s) "
|
f"Don't know how to convert the serializer field {field} ({field.__class__}) "
|
||||||
"to Graphene type" % (field, field.__class__)
|
"to Graphene type"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def convert_serializer_field(field, is_input=True, convert_choices_to_enum=True):
|
def convert_serializer_field(
|
||||||
|
field, is_input=True, convert_choices_to_enum=True, force_optional=False
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Converts a django rest frameworks field to a graphql field
|
Converts a django rest frameworks field to a graphql field
|
||||||
and marks the field as required if we are creating an input type
|
and marks the field as required if we are creating an input type
|
||||||
|
@ -31,7 +33,10 @@ def convert_serializer_field(field, is_input=True, convert_choices_to_enum=True)
|
||||||
graphql_type = get_graphene_type_from_serializer_field(field)
|
graphql_type = get_graphene_type_from_serializer_field(field)
|
||||||
|
|
||||||
args = []
|
args = []
|
||||||
kwargs = {"description": field.help_text, "required": is_input and field.required}
|
kwargs = {
|
||||||
|
"description": field.help_text,
|
||||||
|
"required": is_input and field.required and not force_optional,
|
||||||
|
}
|
||||||
|
|
||||||
# if it is a tuple or a list it means that we are returning
|
# if it is a tuple or a list it means that we are returning
|
||||||
# the graphql type and the child type
|
# the graphql type and the child type
|
||||||
|
@ -72,7 +77,7 @@ def convert_serializer_to_input_type(serializer_class):
|
||||||
for name, field in serializer.fields.items()
|
for name, field in serializer.fields.items()
|
||||||
}
|
}
|
||||||
ret_type = type(
|
ret_type = type(
|
||||||
"{}Input".format(serializer.__class__.__name__),
|
f"{serializer.__class__.__name__}Input",
|
||||||
(graphene.InputObjectType,),
|
(graphene.InputObjectType,),
|
||||||
items,
|
items,
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,11 +1,11 @@
|
||||||
import copy
|
import copy
|
||||||
|
|
||||||
import graphene
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from graphene import InputObjectType
|
from pytest import raises
|
||||||
from py.test import raises
|
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
import graphene
|
||||||
|
|
||||||
from ..serializer_converter import convert_serializer_field
|
from ..serializer_converter import convert_serializer_field
|
||||||
from ..types import DictType
|
from ..types import DictType
|
||||||
|
|
||||||
|
@ -96,8 +96,7 @@ def test_should_regex_convert_string():
|
||||||
|
|
||||||
|
|
||||||
def test_should_uuid_convert_string():
|
def test_should_uuid_convert_string():
|
||||||
if hasattr(serializers, "UUIDField"):
|
assert_conversion(serializers.UUIDField, graphene.String)
|
||||||
assert_conversion(serializers.UUIDField, graphene.String)
|
|
||||||
|
|
||||||
|
|
||||||
def test_should_model_convert_field():
|
def test_should_model_convert_field():
|
||||||
|
|
|
@ -1,13 +1,18 @@
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
from py.test import raises
|
from pytest import raises
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
from graphene import Field, ResolveInfo
|
from graphene import Field, ResolveInfo, String
|
||||||
from graphene.types.inputobjecttype import InputObjectType
|
from graphene.types.inputobjecttype import InputObjectType
|
||||||
|
|
||||||
from ...types import DjangoObjectType
|
from ...types import DjangoObjectType
|
||||||
from ..models import MyFakeModel, MyFakeModelWithDate, MyFakeModelWithPassword
|
from ..models import (
|
||||||
|
MyFakeModel,
|
||||||
|
MyFakeModelWithChoiceField,
|
||||||
|
MyFakeModelWithDate,
|
||||||
|
MyFakeModelWithPassword,
|
||||||
|
)
|
||||||
from ..mutation import SerializerMutation
|
from ..mutation import SerializerMutation
|
||||||
|
|
||||||
|
|
||||||
|
@ -100,6 +105,16 @@ def test_exclude_fields():
|
||||||
assert "created" not in MyMutation.Input._meta.fields
|
assert "created" not in MyMutation.Input._meta.fields
|
||||||
|
|
||||||
|
|
||||||
|
def test_model_serializer_optional_fields():
|
||||||
|
class MyMutation(SerializerMutation):
|
||||||
|
class Meta:
|
||||||
|
serializer_class = MyModelSerializer
|
||||||
|
optional_fields = ("cool_name",)
|
||||||
|
|
||||||
|
assert "cool_name" in MyMutation.Input._meta.fields
|
||||||
|
assert MyMutation.Input._meta.fields["cool_name"].type == String
|
||||||
|
|
||||||
|
|
||||||
def test_write_only_field():
|
def test_write_only_field():
|
||||||
class WriteOnlyFieldModelSerializer(serializers.ModelSerializer):
|
class WriteOnlyFieldModelSerializer(serializers.ModelSerializer):
|
||||||
password = serializers.CharField(write_only=True)
|
password = serializers.CharField(write_only=True)
|
||||||
|
@ -164,6 +179,21 @@ def test_read_only_fields():
|
||||||
), "'cool_name' is read_only field and shouldn't be on arguments"
|
), "'cool_name' is read_only field and shouldn't be on arguments"
|
||||||
|
|
||||||
|
|
||||||
|
def test_hidden_fields():
|
||||||
|
class SerializerWithHiddenField(serializers.Serializer):
|
||||||
|
cool_name = serializers.CharField()
|
||||||
|
user = serializers.HiddenField(default=serializers.CurrentUserDefault())
|
||||||
|
|
||||||
|
class MyMutation(SerializerMutation):
|
||||||
|
class Meta:
|
||||||
|
serializer_class = SerializerWithHiddenField
|
||||||
|
|
||||||
|
assert "cool_name" in MyMutation.Input._meta.fields
|
||||||
|
assert (
|
||||||
|
"user" not in MyMutation.Input._meta.fields
|
||||||
|
), "'user' is hidden field and shouldn't be on arguments"
|
||||||
|
|
||||||
|
|
||||||
def test_nested_model():
|
def test_nested_model():
|
||||||
class MyFakeModelGrapheneType(DjangoObjectType):
|
class MyFakeModelGrapheneType(DjangoObjectType):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
@ -230,7 +260,7 @@ def test_model_invalid_update_mutate_and_get_payload_success():
|
||||||
model_operations = ["update"]
|
model_operations = ["update"]
|
||||||
|
|
||||||
with raises(Exception) as exc:
|
with raises(Exception) as exc:
|
||||||
result = InvalidModelMutation.mutate_and_get_payload(
|
InvalidModelMutation.mutate_and_get_payload(
|
||||||
None, mock_info(), **{"cool_name": "Narf"}
|
None, mock_info(), **{"cool_name": "Narf"}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -245,7 +275,7 @@ def test_perform_mutate_success():
|
||||||
result = MyMethodMutation.mutate_and_get_payload(
|
result = MyMethodMutation.mutate_and_get_payload(
|
||||||
None,
|
None,
|
||||||
mock_info(),
|
mock_info(),
|
||||||
**{"cool_name": "Narf", "last_edited": datetime.date(2020, 1, 4)}
|
**{"cool_name": "Narf", "last_edited": datetime.date(2020, 1, 4)},
|
||||||
)
|
)
|
||||||
|
|
||||||
assert result.errors is None
|
assert result.errors is None
|
||||||
|
@ -253,6 +283,39 @@ def test_perform_mutate_success():
|
||||||
assert result.days_since_last_edit == 4
|
assert result.days_since_last_edit == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_perform_mutate_success_with_enum_choice_field():
|
||||||
|
class ListViewChoiceFieldSerializer(serializers.ModelSerializer):
|
||||||
|
choice_type = serializers.ChoiceField(
|
||||||
|
choices=[(x.name, x.value) for x in MyFakeModelWithChoiceField.ChoiceType],
|
||||||
|
required=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = MyFakeModelWithChoiceField
|
||||||
|
fields = "__all__"
|
||||||
|
|
||||||
|
class SomeCreateSerializerMutation(SerializerMutation):
|
||||||
|
class Meta:
|
||||||
|
serializer_class = ListViewChoiceFieldSerializer
|
||||||
|
|
||||||
|
choice_type = {
|
||||||
|
"choice_type": SomeCreateSerializerMutation.Input.choice_type.type.get("ASDF")
|
||||||
|
}
|
||||||
|
name = MyFakeModelWithChoiceField.ChoiceType.ASDF.name
|
||||||
|
result = SomeCreateSerializerMutation.mutate_and_get_payload(
|
||||||
|
None, mock_info(), **choice_type
|
||||||
|
)
|
||||||
|
assert result.errors is None
|
||||||
|
assert result.choice_type == name
|
||||||
|
kwargs = SomeCreateSerializerMutation.get_serializer_kwargs(
|
||||||
|
None, mock_info(), **choice_type
|
||||||
|
)
|
||||||
|
assert kwargs["data"]["choice_type"] == name
|
||||||
|
assert 1 == MyFakeModelWithChoiceField.objects.count()
|
||||||
|
item = MyFakeModelWithChoiceField.objects.first()
|
||||||
|
assert item.choice_type == name
|
||||||
|
|
||||||
|
|
||||||
def test_mutate_and_get_payload_error():
|
def test_mutate_and_get_payload_error():
|
||||||
class MyMutation(SerializerMutation):
|
class MyMutation(SerializerMutation):
|
||||||
class Meta:
|
class Meta:
|
||||||
|
|
|
@ -11,13 +11,11 @@ This module provides the `graphene_settings` object, that is used to access
|
||||||
Graphene settings, checking for user settings first, then falling
|
Graphene settings, checking for user settings first, then falling
|
||||||
back to the defaults.
|
back to the defaults.
|
||||||
"""
|
"""
|
||||||
from __future__ import unicode_literals
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.test.signals import setting_changed
|
|
||||||
|
|
||||||
import importlib # Available in Python 3.1+
|
import importlib # Available in Python 3.1+
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.test.signals import setting_changed
|
||||||
|
|
||||||
# Copied shamelessly from Django REST Framework
|
# Copied shamelessly from Django REST Framework
|
||||||
|
|
||||||
|
@ -32,6 +30,8 @@ DEFAULTS = {
|
||||||
# Max items returned in ConnectionFields / FilterConnectionFields
|
# Max items returned in ConnectionFields / FilterConnectionFields
|
||||||
"RELAY_CONNECTION_MAX_LIMIT": 100,
|
"RELAY_CONNECTION_MAX_LIMIT": 100,
|
||||||
"CAMELCASE_ERRORS": True,
|
"CAMELCASE_ERRORS": True,
|
||||||
|
# Automatically convert Choice fields of Django into Enum fields
|
||||||
|
"DJANGO_CHOICE_FIELD_ENUM_CONVERT": True,
|
||||||
# Set to True to enable v2 naming convention for choice field Enum's
|
# Set to True to enable v2 naming convention for choice field Enum's
|
||||||
"DJANGO_CHOICE_FIELD_ENUM_V2_NAMING": False,
|
"DJANGO_CHOICE_FIELD_ENUM_V2_NAMING": False,
|
||||||
"DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME": None,
|
"DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME": None,
|
||||||
|
@ -42,8 +42,10 @@ DEFAULTS = {
|
||||||
# https://github.com/graphql/graphiql/tree/main/packages/graphiql#options
|
# https://github.com/graphql/graphiql/tree/main/packages/graphiql#options
|
||||||
"GRAPHIQL_HEADER_EDITOR_ENABLED": True,
|
"GRAPHIQL_HEADER_EDITOR_ENABLED": True,
|
||||||
"GRAPHIQL_SHOULD_PERSIST_HEADERS": False,
|
"GRAPHIQL_SHOULD_PERSIST_HEADERS": False,
|
||||||
|
"GRAPHIQL_INPUT_VALUE_DEPRECATION": False,
|
||||||
"ATOMIC_MUTATIONS": False,
|
"ATOMIC_MUTATIONS": False,
|
||||||
"TESTING_ENDPOINT": "/graphql",
|
"TESTING_ENDPOINT": "/graphql",
|
||||||
|
"MAX_VALIDATION_ERRORS": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
if settings.DEBUG:
|
if settings.DEBUG:
|
||||||
|
@ -78,7 +80,7 @@ def import_from_string(val, setting_name):
|
||||||
module = importlib.import_module(module_path)
|
module = importlib.import_module(module_path)
|
||||||
return getattr(module, class_name)
|
return getattr(module, class_name)
|
||||||
except (ImportError, AttributeError) as e:
|
except (ImportError, AttributeError) as e:
|
||||||
msg = "Could not import '%s' for Graphene setting '%s'. %s: %s." % (
|
msg = "Could not import '{}' for Graphene setting '{}'. {}: {}.".format(
|
||||||
val,
|
val,
|
||||||
setting_name,
|
setting_name,
|
||||||
e.__class__.__name__,
|
e.__class__.__name__,
|
||||||
|
@ -87,7 +89,7 @@ def import_from_string(val, setting_name):
|
||||||
raise ImportError(msg)
|
raise ImportError(msg)
|
||||||
|
|
||||||
|
|
||||||
class GrapheneSettings(object):
|
class GrapheneSettings:
|
||||||
"""
|
"""
|
||||||
A settings object, that allows API settings to be accessed as properties.
|
A settings object, that allows API settings to be accessed as properties.
|
||||||
For example:
|
For example:
|
||||||
|
|
|
@ -5,7 +5,8 @@
|
||||||
GraphiQL,
|
GraphiQL,
|
||||||
React,
|
React,
|
||||||
ReactDOM,
|
ReactDOM,
|
||||||
SubscriptionsTransportWs,
|
graphqlWs,
|
||||||
|
GraphiQLPluginExplorer,
|
||||||
fetch,
|
fetch,
|
||||||
history,
|
history,
|
||||||
location,
|
location,
|
||||||
|
@ -52,108 +53,34 @@
|
||||||
|
|
||||||
var fetchURL = locationQuery(otherParams);
|
var fetchURL = locationQuery(otherParams);
|
||||||
|
|
||||||
// Defines a GraphQL fetcher using the fetch API.
|
|
||||||
function httpClient(graphQLParams, opts) {
|
|
||||||
if (typeof opts === 'undefined') {
|
|
||||||
opts = {};
|
|
||||||
}
|
|
||||||
var headers = opts.headers || {};
|
|
||||||
headers['Accept'] = headers['Accept'] || 'application/json';
|
|
||||||
headers['Content-Type'] = headers['Content-Type'] || 'application/json';
|
|
||||||
|
|
||||||
// Parse the cookie value for a CSRF token
|
|
||||||
var csrftoken;
|
|
||||||
var cookies = ("; " + document.cookie).split("; csrftoken=");
|
|
||||||
if (cookies.length == 2) {
|
|
||||||
csrftoken = cookies.pop().split(";").shift();
|
|
||||||
} else {
|
|
||||||
csrftoken = document.querySelector("[name=csrfmiddlewaretoken]").value;
|
|
||||||
}
|
|
||||||
if (csrftoken) {
|
|
||||||
headers['X-CSRFToken'] = csrftoken
|
|
||||||
}
|
|
||||||
|
|
||||||
return fetch(fetchURL, {
|
|
||||||
method: "post",
|
|
||||||
headers: headers,
|
|
||||||
body: JSON.stringify(graphQLParams),
|
|
||||||
credentials: "include",
|
|
||||||
})
|
|
||||||
.then(function (response) {
|
|
||||||
return response.text();
|
|
||||||
})
|
|
||||||
.then(function (responseBody) {
|
|
||||||
try {
|
|
||||||
return JSON.parse(responseBody);
|
|
||||||
} catch (error) {
|
|
||||||
return responseBody;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Derive the subscription URL. If the SUBSCRIPTION_URL setting is specified, uses that value. Otherwise
|
// Derive the subscription URL. If the SUBSCRIPTION_URL setting is specified, uses that value. Otherwise
|
||||||
// assumes the current window location with an appropriate websocket protocol.
|
// assumes the current window location with an appropriate websocket protocol.
|
||||||
var subscribeURL =
|
var subscribeURL =
|
||||||
location.origin.replace(/^http/, "ws") +
|
location.origin.replace(/^http/, "ws") +
|
||||||
(GRAPHENE_SETTINGS.subscriptionPath || location.pathname);
|
(GRAPHENE_SETTINGS.subscriptionPath || location.pathname);
|
||||||
|
|
||||||
// Create a subscription client.
|
function trueLambda() { return true; };
|
||||||
var subscriptionClient = new SubscriptionsTransportWs.SubscriptionClient(
|
|
||||||
subscribeURL,
|
var headers = {};
|
||||||
{
|
var cookies = ("; " + document.cookie).split("; csrftoken=");
|
||||||
// Reconnect after any interruptions.
|
if (cookies.length == 2) {
|
||||||
reconnect: true,
|
csrftoken = cookies.pop().split(";").shift();
|
||||||
// Delay socket initialization until the first subscription is started.
|
} else {
|
||||||
|
csrftoken = document.querySelector("[name=csrfmiddlewaretoken]").value;
|
||||||
|
}
|
||||||
|
if (csrftoken) {
|
||||||
|
headers['X-CSRFToken'] = csrftoken
|
||||||
|
}
|
||||||
|
|
||||||
|
var graphQLFetcher = GraphiQL.createFetcher({
|
||||||
|
url: fetchURL,
|
||||||
|
wsClient: graphqlWs.createClient({
|
||||||
|
url: subscribeURL,
|
||||||
|
shouldRetry: trueLambda,
|
||||||
lazy: true,
|
lazy: true,
|
||||||
},
|
}),
|
||||||
);
|
headers: headers
|
||||||
|
})
|
||||||
// Keep a reference to the currently-active subscription, if available.
|
|
||||||
var activeSubscription = null;
|
|
||||||
|
|
||||||
// Define a GraphQL fetcher that can intelligently route queries based on the operation type.
|
|
||||||
function graphQLFetcher(graphQLParams, opts) {
|
|
||||||
var operationType = getOperationType(graphQLParams);
|
|
||||||
|
|
||||||
// If we're about to execute a new operation, and we have an active subscription,
|
|
||||||
// unsubscribe before continuing.
|
|
||||||
if (activeSubscription) {
|
|
||||||
activeSubscription.unsubscribe();
|
|
||||||
activeSubscription = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (operationType === "subscription") {
|
|
||||||
return {
|
|
||||||
subscribe: function (observer) {
|
|
||||||
activeSubscription = subscriptionClient;
|
|
||||||
return subscriptionClient.request(graphQLParams, opts).subscribe(observer);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
return httpClient(graphQLParams, opts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Determine the type of operation being executed for a given set of GraphQL parameters.
|
|
||||||
function getOperationType(graphQLParams) {
|
|
||||||
// Run a regex against the query to determine the operation type (query, mutation, subscription).
|
|
||||||
var operationRegex = new RegExp(
|
|
||||||
// Look for lines that start with an operation keyword, ignoring whitespace.
|
|
||||||
"^\\s*(query|mutation|subscription)\\s*" +
|
|
||||||
// The operation keyword should be followed by whitespace and the operationName in the GraphQL parameters (if available).
|
|
||||||
(graphQLParams.operationName ? ("\\s+" + graphQLParams.operationName) : "") +
|
|
||||||
// The line should eventually encounter an opening curly brace.
|
|
||||||
"[^\\{]*\\{",
|
|
||||||
// Enable multiline matching.
|
|
||||||
"m",
|
|
||||||
);
|
|
||||||
var match = operationRegex.exec(graphQLParams.query);
|
|
||||||
if (!match) {
|
|
||||||
return "query";
|
|
||||||
}
|
|
||||||
|
|
||||||
return match[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
// When the query and variables string is edited, update the URL bar so
|
// When the query and variables string is edited, update the URL bar so
|
||||||
// that it can be easily shared.
|
// that it can be easily shared.
|
||||||
|
@ -172,24 +99,45 @@
|
||||||
function updateURL() {
|
function updateURL() {
|
||||||
history.replaceState(null, null, locationQuery(parameters));
|
history.replaceState(null, null, locationQuery(parameters));
|
||||||
}
|
}
|
||||||
var options = {
|
|
||||||
fetcher: graphQLFetcher,
|
function GraphiQLWithExplorer() {
|
||||||
onEditQuery: onEditQuery,
|
var [query, setQuery] = React.useState(parameters.query);
|
||||||
onEditVariables: onEditVariables,
|
|
||||||
onEditOperationName: onEditOperationName,
|
function handleQuery(query) {
|
||||||
headerEditorEnabled: GRAPHENE_SETTINGS.graphiqlHeaderEditorEnabled,
|
setQuery(query);
|
||||||
shouldPersistHeaders: GRAPHENE_SETTINGS.graphiqlShouldPersistHeaders,
|
onEditQuery(query);
|
||||||
query: parameters.query,
|
}
|
||||||
};
|
|
||||||
if (parameters.variables) {
|
var explorerPlugin = GraphiQLPluginExplorer.useExplorerPlugin({
|
||||||
options.variables = parameters.variables;
|
query: query,
|
||||||
}
|
onEdit: handleQuery,
|
||||||
if (parameters.operation_name) {
|
});
|
||||||
options.operationName = parameters.operation_name;
|
|
||||||
|
var options = {
|
||||||
|
fetcher: graphQLFetcher,
|
||||||
|
plugins: [explorerPlugin],
|
||||||
|
defaultEditorToolsVisibility: true,
|
||||||
|
onEditQuery: handleQuery,
|
||||||
|
onEditVariables: onEditVariables,
|
||||||
|
onEditOperationName: onEditOperationName,
|
||||||
|
isHeadersEditorEnabled: GRAPHENE_SETTINGS.graphiqlHeaderEditorEnabled,
|
||||||
|
shouldPersistHeaders: GRAPHENE_SETTINGS.graphiqlShouldPersistHeaders,
|
||||||
|
inputValueDeprecation: GRAPHENE_SETTINGS.graphiqlInputValueDeprecation,
|
||||||
|
query: query,
|
||||||
|
};
|
||||||
|
if (parameters.variables) {
|
||||||
|
options.variables = parameters.variables;
|
||||||
|
}
|
||||||
|
if (parameters.operation_name) {
|
||||||
|
options.operationName = parameters.operation_name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return React.createElement(GraphiQL, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render <GraphiQL /> into the body.
|
// Render <GraphiQL /> into the body.
|
||||||
ReactDOM.render(
|
ReactDOM.render(
|
||||||
React.createElement(GraphiQL, options),
|
React.createElement(GraphiQLWithExplorer),
|
||||||
document.getElementById("editor"),
|
document.getElementById("editor"),
|
||||||
);
|
);
|
||||||
})(
|
})(
|
||||||
|
@ -199,7 +147,8 @@
|
||||||
window.GraphiQL,
|
window.GraphiQL,
|
||||||
window.React,
|
window.React,
|
||||||
window.ReactDOM,
|
window.ReactDOM,
|
||||||
window.SubscriptionsTransportWs,
|
window.graphqlWs,
|
||||||
|
window.GraphiQLPluginExplorer,
|
||||||
window.fetch,
|
window.fetch,
|
||||||
window.history,
|
window.history,
|
||||||
window.location,
|
window.location,
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user