Compare commits

...

181 Commits

Author SHA1 Message Date
Firas Kafri
c52cf2b045
Bump version to 3.2.3 2025-03-13 11:29:45 +03:00
Florian Zimmermann
e69e4a0399
Bugfix: call resolver function in DjangoConnectionField as documented (#1529)
* treat warnings as errors when running the tests

* silence warnings

* bugfix: let DjangoConnectionField call its resolver function

that is, the one specified using DjangoConnectionField(..., resolver=some_func)

* ignore the DeprecationWarning about typing.ByteString in graphql
2025-03-13 11:25:48 +03:00
Sergey Fursov
97deb761e9
fix typed choices, make working with different Django 5x choices options (#1539)
* fix typed choices, make working with different Django 5x choices options

* remove `graphene_django/compat.py` from ruff exclusions
2025-03-13 11:23:51 +03:00
Sergey Fursov
8d4a64a40d
add official Django 5.1 support (#1540) 2024-12-27 13:46:47 +08:00
Alexandre Detiste
269225085d
remove dead code: singledispatch has been in the standard library ... (#1534)
* remove dead code: singledispatch has been in the stard library for many years

(BTW this function does not seems to be used anywhere anymore)

* lint
2024-09-15 21:50:15 +07:00
Markus Richter
28c71c58f7 Bump to 3.2.2 2024-06-12 10:52:45 +08:00
Kien Dang
6f21dc7a94
Not require explicitly set ordering in DjangoConnectionField (#1518)
* Revert "feat!: check django model has a default ordering when used in a relay connection (#1495)"

This reverts commit 96c09ac439.

* Fix assert no warning for pytest>=8
2024-04-18 12:00:31 +08:00
Ülgen Sarıkavak
ea45de02ad
Make use of http.HTTPStatus for response status code checks (#1487) 2024-04-09 03:43:34 +03:00
dependabot[bot]
eac113e136
Bump django from 3.2.24 to 3.2.25 in /examples/cookbook (#1508)
Bumps [django](https://github.com/django/django) from 3.2.24 to 3.2.25.
- [Commits](https://github.com/django/django/compare/3.2.24...3.2.25)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-09 03:39:21 +03:00
Kien Dang
d69c90550f
Bump to 3.2.1 (#1512) 2024-04-09 03:37:32 +03:00
Pablo Alexis Domínguez Grau
3f813d4679
Fix ReadTheDocs builds (#1509)
* Add RTD config file

* Doc fixes to reference main branch instead of master
2024-03-29 12:11:56 +08:00
Alisson Patricio
45c2aa09b5
Allows field's choices to be a callable (#1497)
* Allows field's choices to be a callable

Starting in Django 5 field's choices can also be a callable

* test if field with callable choices converts into enum

---------

Co-authored-by: Kien Dang <mail@kien.ai>
2024-03-21 00:48:51 +08:00
Diogo Silva
ac09cd2967
fix: Fix broke 'get_choices' with restframework 3.15.0 (#1506) 2024-03-18 09:58:47 +08:00
dependabot[bot]
54372b41d5
Bump django from 3.1.14 to 3.2.24 in /examples/cookbook (#1498)
Bumps [django](https://github.com/django/django) from 3.1.14 to 3.2.24.
- [Commits](https://github.com/django/django/compare/3.1.14...3.2.24)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-08 10:50:13 +08:00
Thomas Leonard
96c09ac439
feat!: check django model has a default ordering when used in a relay connection (#1495)
Co-authored-by: Thomas Leonard <thomas@loftorbital.com>
2024-01-30 12:09:18 +03:00
Laurent
b85177cebf
fix: same type list (#1492)
* fix: same type list

* chore: improve test
2024-01-20 16:36:00 +08:00
Firas Kafri
4d0484f312
Bump version 2023-12-20 13:22:33 +03:00
Noxx
c416a2b0f5
Provide setting to enable/disable converting choices to enums globally (#1477)
Co-authored-by: Firas Kafri <3097061+firaskafri@users.noreply.github.com>
Co-authored-by: Kien Dang <mail@kien.ai>
2023-12-20 17:55:15 +08:00
Kien Dang
feb7252b8a
Add support for validation rules (#1475)
* Add support for validation rules

* Enable customizing validate max_errors through settings

* Add tests for validation rules

* Add examples for validation rules

* Allow setting validation_rules in class def

* Add tests for validation_rules inherited from parent class

* Make tests for validation rules stricter
2023-12-20 12:48:45 +03:00
Firas Kafri
3a64994e52
Bump version (#1486) 2023-12-20 12:44:40 +03:00
Kien Dang
db2d40ec94
Remove Django 4.1 (EOL) and add Django 5.0 to CI (#1483) 2023-12-14 11:20:54 +03:00
Kien Dang
62126dd467
Add Python 3.12 to CI (#1481) 2023-12-05 22:11:00 +03:00
danthewildcat
e735f5dbdb
Optimize views (#1439)
* Optimize execute_graphql_request

* Require operation_ast to be found by view handler

* Remove unused show_graphiql kwarg

* Old style if syntax

* Revert "Remove unused show_graphiql kwarg"

This reverts commit 33b3426092.

* Add missing schema validation step

* Pass args directly to improve clarity

* Remove duplicated operation_ast not None check

---------

Co-authored-by: Firas Kafri <3097061+firaskafri@users.noreply.github.com>
Co-authored-by: Kien Dang <mail@kien.ai>
2023-10-29 23:42:27 +08:00
Kien Dang
36cf100e8b
Use ruff format to replace black (#1473)
* Use ruff format to replace black

* Adjust ruff config to be compatible with ruff-format

https://docs.astral.sh/ruff/formatter/#conflicting-lint-rules

* Format

* Replace black with ruff format in Makefile
2023-10-25 11:33:00 +03:00
Kien Dang
e8f36b018d
Fix test Client headers for Django 4.2 (#1465)
* Fix test Client headers for Django 4.2

* Lazy import pkg_resources

since it could be quite heavy

* Remove use of pkg_resources altogether
2023-09-18 23:23:53 +08:00
mnasiri
83d3d27f14
Fix graphiql explorer styles by sending graphiql_plugin_explorer_css_sri param to render_graphiql function of the GraphQlView (#1418) (#1460) 2023-09-14 00:26:18 +08:00
Romain Létendart
ee7560f629
Support displaying deprecated input fields in GraphiQL docs (#1458)
* Update GraphiQL docs URL in docs/settings

And deduplicate link declaration.

* Support displaying deprecated input fields in GraphiQL docs
2023-09-13 09:49:01 +03:00
lilac-supernova-2
67def2e074
Typo fixes (#1459)
* Fix Star Wars spaceship name

* Fix some typos in comments

* Typo fixes

* More typo fixes
2023-09-06 10:29:58 +03:00
mahmoudmostafa0
e49a01c189
adding optional_field in Serializermutation to enfore some fields to be optional (#1455)
* adding optional_fields to enforce fields to be optional

* adding support for all

* adding unit tests

* Update graphene_django/rest_framework/mutation.py

Co-authored-by: Kien Dang <kiend@pm.me>

* linting

* linting

* add missing import

---------

Co-authored-by: Kien Dang <kiend@pm.me>
2023-08-28 00:15:35 +03:00
Thomas Leonard
0473f1a9a3
fix: empty list is not an empty value for list filters even when a custom filtering method is provided (#1450)
Co-authored-by: Thomas Leonard <thomas@loftorbital.com>
2023-08-11 23:24:58 +08:00
Kien Dang
720db1f987
Only release on pypi after tests pass (#1452) 2023-08-11 09:51:59 +03:00
Firas Kafri
4ac3f3f42d
Update __init__.py 2023-08-10 01:12:15 +03:00
Firas Kafri
ee7598e71a
Remove typo 2023-08-09 23:41:57 +03:00
Firas Kafri
05d7fb5396
Bump version 2023-08-09 20:49:51 +03:00
Kien Dang
79b4a23ae0
Miscellaneous CI fixes (#1447)
* Update Makefile

* django master requires at least python 3.10 now

* Allow customizing options passed to tox -e pre-commit

* py.test -> pytest

* Update ruff

* Fix E721

Do not compare types, use `isinstance()`

* Add back black to dev dependencies

* Pin black and ruff versions
2023-08-09 20:48:42 +03:00
Laurent
db34d2e815
fix: foreign key nullable and custom resolver (#1446)
* fix: nullable one to one relation

* fix: makefile
2023-08-09 20:28:26 +03:00
Kien Dang
9a773b9d7b
Use ruff in pre-commit (#1441)
* Use ruff in pre-commit

* Add pyupgrade

* Add isort

* Add bugbear

* Fix B015 Pointless comparison

* Fix B026

* B018 false positive

* Remove flake8 and isort config from setup.cfg

* Remove black and flake8 from dev dependencies

* Update black

* Show list of fixes applied with autofix on

* Fix typo

* Add C4 flake8-comprehensions

* Add ruff to dev dependencies

* Fix up
2023-08-06 01:47:00 +03:00
Kien Dang
45a732f1db
Prevent duplicate CI runs, also work with PRs from forks (#1443)
* Prevent duplicate CI runs

* Trigger CI on pull requests from forks
2023-08-06 01:45:10 +03:00
Kien Dang
5eb5fe294a
Remove Python 3.7 (EOL since EOL since 2023-06-27) from CI (#1440)
* Remove Python 3.7 (EOL since EOL since 2023-06-27) from CI

* Remove unused context

* Use pyupgrade --py38-plus in pre-commit
2023-08-04 11:15:23 +03:00
James
5d7a04fce9
Update mutation.py to serialize Enum objects into input values (#1431)
* Fix for issue #1385: Update mutation.py to serialize Enum objects into input values for ChoiceFields

* Update graphene_django/rest_framework/mutation.py

Co-authored-by: Steven DeMartini <1647130+sjdemartini@users.noreply.github.com>

---------

Co-authored-by: Steven DeMartini <1647130+sjdemartini@users.noreply.github.com>
2023-07-27 02:41:40 +03:00
Firas Kafri
3172710d12
exclude 'fans' from ReporterForm tests (#1434) 2023-07-18 20:35:51 +03:00
Tom Dror
b1abebdb97
Support base class relations and reverse for proxy models (#1380)
* support reverse relationship for proxy models

* support multi table inheritence

* update query test for multi table inheritance

* remove debugger

* support local many to many in model inheritance

* format and lint

---------

Co-authored-by: Firas K <3097061+firaskafri@users.noreply.github.com>
2023-07-18 20:17:45 +03:00
Laurent
0de35ca3b0
fix: fk resolver permissions leak (#1411)
* fix: fk resolver permissions leak

* fix: only one query for 1o1 relation

* tests: added queries count check

* fix: docstring

* fix: typo

* docs: added warning to authorization

* feat: added bypass_get_queryset decorator
2023-07-18 15:16:52 +03:00
Firas Kafri
2fafa881a8
Bump version 2023-07-18 15:13:58 +03:00
Steven DeMartini
cd43022283
Maintain JSONField in graphene_django.compat module (#1429)
Fixes https://github.com/graphql-python/graphene-django/issues/1428

This should improve backwards compatibility, fixing issues in downstream
packages (notably graphene-django-cud
https://github.com/tOgg1/graphene-django-cud/issues/109, and also
graphene-django-extras, both of which depended on
`graphene_django.compat.JSONField`).

Co-authored-by: Steven DeMartini <sjdemartini@users.noreply.github.com>
2023-07-18 15:11:30 +03:00
Jeongseok Kang
3f061a0c50
docs: Update location of GraphQL Relay Specification (#1432) 2023-07-18 15:10:22 +03:00
Firas Kafri
e950164c8e
Bump version to 3.1.2 2023-06-17 09:29:18 +03:00
Steven DeMartini
2358bd30a4
Update compat.py MissingType results after PGJSONField removal (#1423)
As mentioned in https://github.com/graphql-python/graphene-django/pull/1421/files#r1221711648
2023-06-07 20:06:37 +03:00
Dulmandakh
3e7a16af73
CI: remove Django 4.0 (#1422)
* CI: remove Django 4.0

* fix tags
2023-06-07 17:36:51 +03:00
Dulmandakh
8fa8aea3c0
remove JSONField compat (#1421)
* remove JSONFIeld compat

* fix black
2023-06-07 17:36:29 +03:00
Dulmandakh
c925a32dc3
CI: add Django 4.2 (#1420)
* CI: add Django 4.2

* fix tox
2023-06-07 16:52:40 +03:00
Sezgin ACER
8934393909
Add check for serializers.HiddenField on fields_for_serializer function (#1419)
* Add check for `serializers.HiddenField` on fields_for_serializer function

* Add pre-commit changes
2023-06-06 09:20:32 +03:00
Steven DeMartini
520ddeabf6
Fix graphiql explorer styles by including official CSS, and update local example app for testing (#1418)
* Add venv and .venv to gitignore since common venv paths

* Update cookbook-plain app requirements and local-dev notes

This also adds the DEFAULT_AUTO_FIELD to the app's Django settings to
resolve this warning when running `migrate`:

```
ingredients.Category: (models.W042) Auto-created primary key used when not defining a primary key type, by default 'django.db.models.AutoField'.
	HINT: Configure the DEFAULT_AUTO_FIELD setting or the IngredientsConfig.default_auto_field attribute to point to a subclass of AutoField, e.g. 'django.db.models.BigAutoField'.
```

* Fix #1417 graphiql explorer styles by including official CSS

Like in the official graphiql-plugin-explorer example here
6198646919/packages/graphiql-plugin-explorer/examples/index.html (L26-L29)

Resolves https://github.com/graphql-python/graphene-django/issues/1417

* Update GraphiQL version

---------

Co-authored-by: Steven DeMartini <sjdemartini@users.noreply.github.com>
Co-authored-by: Kien Dang <mail@kien.ai>
2023-06-02 11:48:53 +03:00
Kien Dang
38709d8396
Correct schema write test (#1416)
<Mock>.called_once() just returns a Mock, so assert <Mock>.called_once()
always passes. We want <Mock>.assert_called_once().
2023-05-27 16:53:22 +03:00
Kien Dang
63fd98393f
Set pypi GH action to latest v1 (#1415) 2023-05-27 16:26:52 +03:00
Firas Kafri
4e5acd4702
Fix linting issues (#1412) 2023-05-24 16:13:23 +03:00
Firas Kafri
ebf49431e9
Bump version 2023-05-24 16:10:22 +03:00
Firas Kafri
b75904d4c8
long_description_content_type='text/markdown' 2023-05-24 16:07:45 +03:00
Firas Kafri
7fe661d423
Bump version 2023-05-24 16:03:14 +03:00
ndpu
be17278b49
Add DjangoFormInputObjectType to forms/types (#1325)
* Add DjangoFormInputObjectType to forms/types

InputObjectType derived class which gets fields from django form.
Type of fields with choices (converted to enum) is set to custom scalar
type (using Meta.object_type) to dynamically convert enum values back.

* Correct Reporter model a_choice field type according to CHOICES tuple

* Add tests for DjangoFormInputObjectType

* Add pyenv files to .gitignore

* Fix pyupgrade

* Fix tests

* Add docs

* Fix docs example

---------

Co-authored-by: Firas Kafri <3097061+firaskafri@users.noreply.github.com>
2023-05-24 15:58:50 +03:00
ndpu
a6596273cf
Update docs/requirements.txt (#1410)
* change Sphinx version from 1.5.3 to 7.0.0

* change sphinx-autobuild version from 0.7.1 to 2021.3.14

* add pygments-graphql-lexer to docs/requirements.txt
2023-05-24 15:55:20 +03:00
Mykhailo Havelia
388ca41d64
fix: use execution_context_class attribute for GraphQLView (#1398)
* fix: use execution_context_class attribute for GraphQLView
2023-05-24 15:54:44 +03:00
Firas Kafri
72a3700856
Update Development Status calassifier (#1409) 2023-05-05 13:04:47 +03:00
shukryzablah
09f9b6d2f1
Remove redundant call to validate (#1393)
* Remove redundant call to validate

The call to `validate` in the django view is redundant with the validation call in graphql-core.

* Remove whitespace

---------

Co-authored-by: Firas K <3097061+firaskafri@users.noreply.github.com>
2023-05-05 13:04:22 +03:00
Firas Kafri
6f13d28b6e
Update README.md (#1408)
* Update README.md

* Delete README.rst

* Update long_description source to be from README.md
2023-05-04 23:54:09 +03:00
Firas Kafri
ce7492b5ae
Delete README.rst 2023-05-04 23:46:15 +03:00
Kien Dang
52f992183f
Add GraphiQL Explorer plugin (#1397) 2023-05-04 22:06:10 +03:00
Ülgen Sarıkavak
8540a9332c
Add support for Python 3.11 (#1365)
* Add support for Python 3.11

* Fix Python 3.11 compatibility matrix

* Add temporary fix for default enum description

---------

Co-authored-by: Firas Kafri <firaskafri@Firass-MacBook-Pro-2.local>
2023-05-04 15:19:24 +03:00
Firas K
af8888f58e
Upgrade github actions versions, default python and dev dependencies (#1407)
* Use Python 3.10 for deployments on PyPi

* Update gh-action-pypi-publish version

* Update python version

* Update checkout and setup-python versions

* Upgrade dev dependencies

* fromat examples and few files to follow black new version

* Upgrade pytest version

---------

Co-authored-by: Firas Kafri <firaskafri@Firass-MacBook-Pro-2.local>
2023-05-03 13:25:16 +03:00
Steven DeMartini
c1a22bfd91 Add pre-commit to dev-setup
pre-commit is currently configured nicely but hasn't been part of the
Makefile setup and isn't mentioned in the contributing notes. This
change makes it so that pre-commit is installed as a part of the dev
setup, whereas before it had to be manually installed.
2023-05-03 12:08:22 +03:00
Kien Dang
95a0642818 fix: fix graphiql request failure 2023-05-03 12:07:54 +03:00
Firas K
a8ceca77ed Bump version 2023-05-03 11:54:46 +03:00
Steven DeMartini
20a6cecc4c Add test validating query performance with select_related + prefetch_related
This test passes after reverting the `CustomField` resolver change
introduced in
https://github.com/graphql-python/graphene-django/pull/1315, but fails
with that resolver code present. For instance, adding back the resolver
code gives a test failure showing:

```
Failed: Expected to perform 2 queries but 11 were done
```

This should ensure there aren't regressions that prevent
query-optimization in the future.
2023-05-03 11:37:17 +03:00
Steven DeMartini
9796e93fc7 Remove obsolete tests and add note about rationale 2023-05-03 11:37:17 +03:00
Steven DeMartini
f67c5dbc8c Revert field resolver logic to fix poor query performance
This reverts the change to `convert_field_to_djangomodel` introduced in
https://github.com/graphql-python/graphene-django/pull/1315 for the
reasons discussed here
https://github.com/graphql-python/graphene-django/pull/1315/files#r1015659857.
As mentioned there, without reverting this code, "queries are forced
every time an object is resolved, making an exponential number of
queries when nesting without any possibility of optimizing".

That regression prevented `graphene-django-optimizer` from working with
`graphene-django` v3.0.0b9+ (where this change first was published), as
discussed in
https://github.com/graphql-python/graphene-django/issues/1356#issuecomment-1284718187,
https://github.com/tfoxy/graphene-django-optimizer/issues/86, and
https://github.com/tfoxy/graphene-django-optimizer/pull/83#issuecomment-1451987397.

For now, this marks the two tests that depended on this problematic code
as "expected to fail", and perhaps they can be reintroduced if there's a
way to support this logic in a way that does not prevent
`select_related` and `prefetch_related` query-optimization and introduce
nested N+1s.

As mentioned here
https://github.com/graphql-python/graphene-django/pull/1315#issuecomment-1468594361,
this is blocking upgrade to graphene-django v3 for many users, and
fixing this would allow many to begin upgrading and contributing to keep
graphene-django going.
2023-05-03 11:37:17 +03:00
Firas K
34cc86063b
☂️ v3.0.1 ☂️ 2023-04-29 20:26:39 +03:00
Firas K
a335042dbe
☂️ v3.0.1 ☂️ 2023-04-29 20:26:05 +03:00
Firas K
df3c0bf75b Update filtering.rst 2023-04-26 22:46:40 +03:00
Firas K
7e1a1d1fb8 Update django-filter url 2023-04-26 22:46:40 +03:00
Kien Dang
3283d0b1be Update GraphiQL to 2.4.1 2023-04-17 09:17:21 +03:00
Bendik Eger
1d814c54c4 Fix schema print with -.graphql 2023-04-10 14:52:10 +03:00
Josh Warwick
0beb3385df import error resolved? 2023-03-01 11:13:00 +03:00
Josh Warwick
3b41aaf7bf Remove promise based middleware 2023-03-01 11:13:00 +03:00
Kien Dang
d18cab8aa4 Update graphiql to 1.4.7 2022-12-25 01:09:13 +03:00
Ülgen Sarıkavak
daa0ab046b
Update pre-commit tools (#1364) 2022-11-23 01:16:14 +03:00
Omar Mirza
a000d58514 Clarify cookbook example READMEs
Currently the relay cookbook's readme has a link to the plain tutorial
page. The plain cookbook readme also instructs the user to change
directory into the directory for the relay example. This change fixes
both issues.

Also changed the title for the relay example to specify that it uses
relay.
2022-11-15 09:56:28 +03:00
Yuekui
86c5309c45
Fix broken UT due to pytest import error (#1368) 2022-11-14 13:56:18 +03:00
Nikolai Røed Kristiansen
4517e32224
👷 Add pre-commit (#1336)
* 🔧 Add pre-commit config

Similar to graphene and graphene-sqlalchemy

* ⬆ Bump black

* 👷 Lint on CI

* ⬆ Bump flake8-black

* 🔧 Keep excluding migrations

* ⬆ Bump flake8

* 🔧 Remove black and flake8 from tox config

* ⬆ Update pre-commit versions

* Upgrade syntax to python 3.7+

* Format with pre-commit

dedent docs/schema.py to allow formatting

* Fix tests on python 3.7
2022-10-19 17:10:30 +03:00
Syrus Akbary
f24cbd5148
Fix custom foreignkey resolvers (#1361)
* Fix custom foreignkey resolvers

* Fixed assert name conversion

* Fix lint
2022-10-17 16:57:24 +02:00
Firas K
ed7c995d8c
☂️ v3.0.0 ☂️ (#1355) 2022-09-26 15:08:32 +03:00
Syberen van Munster
bb03306075
Move testing endpoint to settings (#1105)
* Import testing endpoint from graphene settings

* Add documentation for TESTING_ENDPOINT setting

* Remove empty lines

* Run formatter

Co-authored-by: Firas K <3097061+firaskafri@users.noreply.github.com>
2022-09-26 01:56:22 +03:00
Firas K
c697e5c8c1
Bump version to v3.0.0b9 (#1353) 2022-09-26 01:27:22 +03:00
Suyeol Jeon
a78114ada3
Add support to persist GraphQL headers in GraphiQL (#1209) 2022-09-24 17:41:14 +03:00
Alan Rivas
07940aa5f5
Update tutorial-relay.rst (#1220) 2022-09-24 16:03:45 +03:00
Gabriel Lacroix
9a60589732
Make instructions runnable without tweaking (#1224)
Introduces two changes to make sure the instructions in the tutorial don't require debugging:

- Add `cd ..` when first syncing the database so that `manage.py` is accessible in the working directory.
- Change `cookbook.ingredients.apps.IngredientsConfig.name` to `cookbook.ingredients` from `ingredients` to prevent the following exception:

  ```python
django.core.exceptions.ImproperlyConfigured: Cannot import 'ingredients'. Check that 'cookbook.ingredients.apps.IngredientsConfig.name' is correct.
```
2022-09-24 16:02:33 +03:00
Forest Anderson
0b2cc4ecb2
Fixed deprecation warning (#1313) 2022-09-24 16:00:45 +03:00
belkka
97442f9cee
Fix code examples in queries.rst (#1265)
* Fix code examples in queries.rst

Code example in Arguments section doesn't work as stated in its comment — if "foo" or "bar" are not declare in the graphql query, it will be an error, not they become None.

Code example in Info section has invalid indentation, `resolve_questions()` seems to be a `Query` method, but it's indented as module-level function.

* Fix indentation in query examples

* Enable syntax highlight for graphql queries
2022-09-24 16:00:22 +03:00
belkka
60b3032014
Fix type hint for DjangoObjectTypeOptions.model (#1269)
Proper type is `typing.Type[Model]`, not `Model`
2022-09-24 16:00:12 +03:00
Craig
05d3df92e7
Delay assignment of csrftoken (#1289) 2022-09-24 15:59:53 +03:00
Firas K
541caa117e
Fixes related to pr#1412 (#1352)
* fix: setup.py graphene dependency

* fix: graphene_django/tests/test_get_queryset.py format

Co-authored-by: Firas Kafri <firaskafri@Firass-MacBook-Pro-2.local>
2022-09-24 15:50:40 +03:00
Semyon Pupkov
0f40da7b31
Make errors in form mutation non nullable (#1286) 2022-09-23 11:47:10 +03:00
Thomas Leonard
5d81ba04f9
fix: unit test for graphene pr#1412 (#1315)
* Issue #1111: foreign key should also call get_queryset method

* fix: test for graphene PR https://github.com/graphql-python/graphene/pull/1412

Co-authored-by: Thomas Leonard <thomas@loftorbital.com>
2022-09-23 11:45:02 +03:00
Firas K
b2f83eb277
Bump version to 3.0.0b8 (#1348) 2022-09-23 11:38:11 +03:00
andrei-datcu
56892d7f4b
Cast translated description for DecimalField (#1255)
* Cast translated description for DecimalField

https://github.com/graphql-python/graphene-django/pull/976 casts all the description fields to strings to prevent schema printing from failing whenever the description is a lazy translated string. The `DecimalField` however got in after the v3 merge and it currently misses the cast.

* Fix row size
2022-09-22 19:13:30 +01:00
Yiğit Y. Er
4f315c365d
minor fix on schema.py part (#1306)
The documentation already suggests importing ObjectType from graphene, graphene.ObjectType is not necessary while defining the Query class.
2022-09-22 19:10:52 +01:00
Thomas Leonard
a53ded611b
feat: update name of DjangoFilterConnectionField type input to be consistent with graphene (Issue #1316) (#1317)
Co-authored-by: Thomas Leonard <thomas@loftorbital.com>
2022-09-22 19:09:29 +01:00
Thomas Leonard
37848fa2df
fix: convert Django BigIntegerField to BigInt GraphQL type (#1318)
Co-authored-by: Thomas Leonard <thomas@loftorbital.com>
2022-09-22 19:09:11 +01:00
Thomas Leonard
3473fe025e
fix: backward pagination (#1346)
Co-authored-by: Thomas Leonard <thomas@loftorbital.com>
Co-authored-by: Laurent  <laurent.riviere.pro@gmail.com>
2022-09-22 16:01:28 +01:00
Thomas Leonard
42a40b4df0
chore: update dev dependencies (#1345)
Co-authored-by: Thomas Leonard <thomas@loftorbital.com>
2022-09-22 10:26:21 +01:00
Nikolai Røed Kristiansen
8ae576394e
💥 Stop supporting EOL djangos and pythons (#1337)
* 💥 Stop supporting EOL djangos and pythons

* 👷 Run only supported version in test workflow
2022-09-19 14:31:04 +02:00
Thomas Leonard
2aeb86ba3b
fix: backward pagination indexing error when using bigger last argument than total number of elements (#1344)
Co-authored-by: Thomas Leonard <thomas@loftorbital.com>
2022-09-06 14:00:13 +02:00
Nikolai Røed Kristiansen
5f1731dca3
Fix: Use .formatted instead of format_error (#1327) & Fix tests
* 👽 Use .formatted instead of format_error

*  Fix test with newer graphene

null default values (graphql-python/graphene@03277a5)
no more trailing newlines
2022-08-15 11:41:39 +02:00
Aaron Forsander
f6ec0689c1
Fix documentation references: op_name -> operation_name (#1312) 2022-03-03 16:58:48 +03:00
Peter Paul Kiefer
0bb9f1ca60
I found another wrong link in the filter dokumentation see #1309 (#1311)
* fixed broken links to graphene filter documentation (master->main)

* #1295 There is still a wrong link to github

The referenced example is in main branch
but the link goes to the master branch which still exists.

Co-authored-by: Peter Paul Kiefer <dafisppk@gmail.com>
2022-02-13 08:50:53 +03:00
Peter Paul Kiefer
bf8fd7696b
fixed broken links to graphene filter documentation (master->main) (#1309)
Co-authored-by: Peter Paul Kiefer <dafisppk@gmail.com>
2022-02-12 17:31:45 +03:00
Keith
775644b536
Update requirements to the official graphene 3.0 release (#1290) 2022-01-22 20:04:30 +00:00
Jarkko Piiroinen
e1a7d19833
Convert DecimalField to Decimal instead of Float in DRF and form converters (#1277)
* Convert serializer DecimalField to Decimal

* Convert form DecimalField to Decimal
2022-01-18 17:03:08 +03:00
Tim Schilling
5d5d7f1815
Django v4, python 3.10 support for graphene-django v3 (#1281)
Co-authored-by: Yair Silbermintz <MisterGlass@users.noreply.github.com>
2022-01-07 20:26:07 +00:00
dependabot[bot]
32667b5407
Bump django from 3.1.8 to 3.1.14 in /examples/cookbook (#1283)
Bumps [django](https://github.com/django/django) from 3.1.8 to 3.1.14.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.1.8...3.1.14)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-12-10 12:58:03 +03:00
Chouaib Lammas
ef9d67302e
Fix ingredient model (#1258)
Add the required positional argument: 'on_delete'
2021-12-10 12:51:10 +03:00
dependabot[bot]
1e4b03b975
Bump django from 3.1.8 to 3.1.14 in /examples/cookbook-plain (#1282)
Bumps [django](https://github.com/django/django) from 3.1.8 to 3.1.14.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.1.8...3.1.14)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-12-10 12:49:16 +03:00
Paul Bailey
e7f7d8da07
Add missing auto fields (#1212)
* add missing auto fields

* add missing auto fields

* skip small auto field sometimes

* make small auto optional

* make small auto optional
2021-06-11 13:41:02 -07:00
Rainshaw
623d0f219e
update js version (#1188) 2021-04-20 23:05:46 -07:00
Eero Ruohola
608af578d4
Fix broken form.save() call in DjangoFormMutation.perform_mutate (#1155)
Django's plain (non-model) forms don't have the `save` method, so
calling this would just result in an `AttributeError` before this
change.

Resolves #1152
2021-04-10 20:30:15 -07:00
dependabot[bot]
26a851a523
Bump django from 3.1.6 to 3.1.8 in /examples/cookbook-plain (#1157)
Bumps [django](https://github.com/django/django) from 3.1.6 to 3.1.8.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.1.6...3.1.8)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-10 19:28:41 -07:00
dependabot[bot]
762eaabd04
Bump django from 3.1.6 to 3.1.8 in /examples/cookbook (#1156)
Bumps [django](https://github.com/django/django) from 3.1.6 to 3.1.8.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.1.6...3.1.8)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-04-10 19:28:12 -07:00
Thomas Leonard
80ea51fc3b
Add typed filters (v3) (#1148)
* feat: add TypedFilter which allow to explicitly give a filter input GraphQL type

* Fix doc typo

Co-authored-by: Thomas Leonard <thomas@loftorbital.com>
2021-03-31 10:31:45 -07:00
Kuba Misiorny
3cf940d0c8
Add ability to pass execution_context_class to GraphQLView.as_view() (#1109)
* Add ability to pass `execution_context_class` to `GraphQLView.as_view()`

Currently when passing `execution_context_class` like this:

```
GraphQLView.as_view(execution_context_class=CustomContext)
```

you get the following error from `View.as_view()`
```
TypeError: GraphQLView() received an invalid keyword 'execution_context_class'. as_view only accepts arguments that are already attributes of the class.
```

this PR fixes the `hasattr` check in `.as_view`.

Fixes: #1072

* make black happy

removed whitespace
2021-03-31 10:31:20 -07:00
Ülgen Sarıkavak
3058118e8f
Tox & actions updates (#1143)
* Update Django's main branch name
* Add Python 3.9 to tox
* Update base gh action versions
* Add Django 3.2 to tests
* Remove redundant Django 1.11 references
* Update setup.py for new Django and Python versions
2021-03-24 09:32:37 +03:00
dependabot[bot]
594ca6e25e
Bump django from 3.0.7 to 3.1.6 in /examples/cookbook (#1150)
Bumps [django](https://github.com/django/django) from 3.0.7 to 3.1.6.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.0.7...3.1.6)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-03-20 22:38:50 -07:00
dependabot[bot]
573d38e13e
Bump django from 3.0.7 to 3.1.6 in /examples/cookbook-plain (#1149)
Bumps [django](https://github.com/django/django) from 3.0.7 to 3.1.6.
- [Release notes](https://github.com/django/django/releases)
- [Commits](https://github.com/django/django/compare/3.0.7...3.1.6)

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2021-03-20 22:38:38 -07:00
Tonye Jack
212524fd8c
Cleaned up unused imports and variables. (#1146)
* Cleaned up used imports and variables.
* Optimized imports.
* Fixed mixed imports.
2021-03-18 12:26:02 +03:00
Sebastián Sastoque H
fe66b48d38
Fix main branch tests failing due to wrong instancing of Missing class (#1135)
Co-authored-by: Sebastian Hernandez <sebastian@rhinoafrica.com>
2021-03-02 10:46:35 -08:00
Jason Kraus
e9f25ecf2d
enhancement: DjangoDebugContext captures exceptions and allows captured stack traces to be queried (#1122) 2021-03-02 10:45:46 -08:00
Thomas Leonard
6046a710c8
fix: declaration of required variable in filters v3 (#1137)
* fix: declaration of required variable

* Add unit test

* Fix formating

Co-authored-by: Thomas Leonard <thomas@loftorbital.com>
2021-02-26 14:44:43 -08:00
Sebastián Sastoque H
ea593b673f
Fix: Use resolver passed as an attribute (#1131)
Co-authored-by: Sebastian Hernandez <sebastian@rhinoafrica.com>
2021-02-23 09:50:19 -08:00
Tonye Jack
5cee41407c
Added GraphQLTransactionTestCase (#1099)
* Added GraphQLTransactionTestCase

- Adds support for testing code that is executed within a transaction

Reference: https://docs.djangoproject.com/en/3.1/topics/testing/tools/#django.test.TransactionTestCase
```
 For instance, you cannot test that a block of code is executing within a transaction, as is required when using select_for_update(). In those cases, you should use TransactionTestCase.
```

* Update testing.py

* Update testing.py

* Fixed formatting.

* Updated docs.

* Updated test.

* Update testing.rst
2021-02-22 20:24:02 -08:00
Thomas Leonard
2d4ca0ac7b
Add enum support to filters and fix filter typing (v3) (#1119)
* - Add filtering support for choice fields converted to graphql Enum (or not)
- Fix type of various filters (used to default to String)
- Fix bug with contains introduced in previous PR
- Fix bug with declared filters being overridden (see PR #1108)
- Fix support for ArrayField and add documentation

* Fix for v3

Co-authored-by: Thomas Leonard <thomas@loftorbital.com>
2021-02-22 20:21:32 -08:00
andrei-datcu
5ce4553244
Fix schema dump on windows (#1123)
Without explicitly setting the encoding to "utf-8" I get the following error on windows (python 3.9)

```
  File "D:\env\lib\site-packages\graphene_django\management\commands\graphql_schema.py", line 115, in handle
    self.get_schema(schema, out, indent)
  File "D:\env\lib\site-packages\graphene_django\management\commands\graphql_schema.py", line 72, in get_schema
    self.save_graphql_file(out, schema)                                                                                   
  File "D:\env\lib\site-packages\graphene_django\management\commands\graphql_schema.py", line 59, in save_graphql_file      
    outfile.write(print_schema(schema.graphql_schema))                                                                    
  File "C:\Users\u\AppData\Local\Programs\Python\Python39\lib\encodings\cp1252.py", line 19, in encode 
    return codecs.charmap_encode(input,self.errors,encoding_table)[0]
```
2021-02-22 20:20:59 -08:00
Yves-Gwenael Bourhis
007768b454
Fix subscriptions in JS (#1124) 2021-02-22 20:19:20 -08:00
Jiahao Li
beb2e4aae3
Doc clarification for headers arg in testing utils (#1117)
I think it might be helpful to add an explicit hint that HTTP headers should be prepended with `HTTP_` as required by `django.test.Client` (at least it was not super obvious to me when I tried to use it).
2021-02-22 20:13:49 -08:00
Tonye Jack
52880166bd
Remove unused imports (#1127)
* Remove unused imports

* Update converter.py
2021-02-22 20:10:30 -08:00
Jason Kraus
4573d3db53
Fix test main (#1126)
Co-authored-by: Thomas Leonard <thomas@loftorbital.com>
2021-02-20 14:26:06 -08:00
Jason Kraus
c3404a9793
document purpose of DjangoConnectionField (#1107) 2021-02-02 09:58:43 -08:00
Jason Kraus
d9ab8acf26
document auth pattern: return None with resolve method (#1106)
* document auth pattern: return None with resolve method

* (doc, auth): also show that one can raise an exception in a resolve method
2021-02-02 09:58:21 -08:00
Lucas
5dea6ffa41
Support "contains" and "overlap" filtering (v3) (#1101)
* Support contains/overlap filters

* Remove unused fixtures
2021-01-18 21:39:22 -08:00
Jason Kraus
bcc7f85dad
Add BlankField and mount enums using it v3 (#1096)
* Add BlankField and mount enums using it

* fix lint error from duplicate import

Co-authored-by: Jonathan Kim <jkimbo@gmail.com>
2021-01-11 16:34:50 -08:00
Thomas Leonard
10e48c27b7
Validate in and range filter inputs (#1090)
Co-authored-by: Thomas Leonard <thomas@loftorbital.com>
2021-01-09 19:15:21 -08:00
Ülgen Sarıkavak
ea84827ab8
Fix backward compability on GraphQLTestCase._client setter (#1094) 2021-01-09 19:14:54 -08:00
Lucas
fdeadf5ce5
Fix project setup (#1087)
* Fix project setup

* Fix test_should_query_postgres_fields
2021-01-02 09:46:00 -08:00
Jason Kraus
8324d47999 Merge branch 'v2' into main 2020-12-30 22:31:41 -08:00
Jason Kraus
c049ab7470
WIP: Merge master into v3 (#1086)
* merge master into v3

* fix order_by snake casing by checking if value is None, switch executor to execution_context_class since schema.execute no longer supports executor

* fix linting by removing duplicate defintion and test of convert_form_field_to_string_list
2020-12-30 15:37:57 -08:00
Jonathan Kim
48ed516b5e Trigger tests 2020-08-12 07:29:42 +01:00
Jonathan Kim
b1b57d815a
v3.0.0b6 2020-08-12 07:18:02 +01:00
Jonathan Kim
53023423b6 Fix import 2020-08-12 07:14:48 +01:00
Jonathan Kim
33c6a54414 Merge branch 'master' into v3 2020-08-12 07:06:35 +01:00
Jonathan Kim
da9f41c295 Rename get_resolver -> wrap_resolve and fix tests 2020-08-06 20:45:30 +01:00
Jonathan Kim
62f95f2858
v3.0.0b5 2020-08-06 20:35:44 +01:00
Jonathan Kim
6cfcddac5a Merge branch 'master' into v3 2020-08-06 20:35:08 +01:00
Jonathan Kim
965ebdee13 Merge branch 'master' into v3 2020-06-27 11:14:09 +01:00
Jonathan Kim
88f6ec458c v3.0.0b4 2020-06-27 10:44:06 +01:00
Jean-Louis Fuchs
08d0cce55a
Move to_const function from Graphene into Graphene-Django (#992) (#996)
Co-authored-by: Jonathan Kim <jkimbo@gmail.com>
2020-06-27 10:43:25 +01:00
Jonathan Kim
f8b88fdc9a
v3.0.0b3 2020-06-11 11:10:45 +01:00
Radosław Kowalski
1f752b6cad
Warn if fields or exclude are not defined on DjangoObjectType (#981) 2020-06-11 11:09:52 +01:00
DoctorJohn
17146f9b01
Make v3 django choice field enum naming default (in v3) (#982)
Co-authored-by: Jonathan Kim <jkimbo@gmail.com>
2020-06-10 17:32:07 +01:00
DoctorJohn
85976ffb1f
Start raising DeprecationWarnings for using only_fields and exclude_fields (v3) (#980) 2020-06-10 17:30:24 +01:00
DoctorJohn
26c4c48abc
Fix that generated schemas could contain empty descriptions (v3) (#984) 2020-06-10 17:21:37 +01:00
Jonathan Kim
d9c187ffc2
v3.0.0b2 2020-06-06 17:09:15 +01:00
DoctorJohn
b320b94a73
(v3) Cast potentially translated strings used as descriptions (#976) 2020-06-06 17:08:10 +01:00
Jonathan Kim
fb90cb78b3 Merge branch 'master' into v3 2020-05-22 11:17:36 +01:00
Jonathan Kim
680bf72871
Default camelcase errors (#968) 2020-05-21 16:42:15 +01:00
Jonathan Kim
c79b12b5a3 v3.0.0b1 2020-05-09 12:55:35 +01:00
Jonathan Kim
9b41472922 Merge branch 'master' into v3 2020-05-09 12:50:39 +01:00
Jonathan Kim
07c51092e1
Rename op_name to operation_name (#941) 2020-05-09 12:25:24 +01:00
Jean-Louis Fuchs
10d22de98e
graphql 3.0 and graphene 3.0 final rebase (#951) 2020-05-09 12:13:47 +01:00
Jonathan Kim
77b9832606 Add GitHub actions (#947) 2020-05-01 14:08:34 +01:00
kimbriancanavan
82d8dbc893
Convert MultipleChoiceField to List of type String (#611) 2020-04-25 14:22:09 +01:00
Jean-Louis Fuchs
f33223daa7
Make tests order independent (v3) (#940)
* Completes 'Make tests order independent'
2020-04-20 14:01:43 +01:00
Jonathan Kim
9bb0554c94 Merge branch 'master' into v3 2020-04-20 13:24:19 +01:00
Jonathan Kim
657208054a Merge branch 'master' into v3 2020-04-16 14:29:38 +01:00
Ülgen Sarıkavak
dd0d6ef28f
Python 3 (#904)
* Remove Python 2 support

* Upgrade Python & Django versions

* Remove unsupported Django versions
* Remove unsupported Python versions
* Add Python 3.8

* Drop support for django-filter < 2

* Update LoginRequiredMixin doc link

* Remove redundant import

* Resolve RemovedInDjango40Warning warnings

* gql/graphene-django/graphene_django/tests/test_converter.py:175:
RemovedInDjango40Warning: django.utils.translation.ugettext_lazy() is
deprecated in favor of django.utils.translation.gettext_lazy().

* graphene-django/graphene_django/utils/utils.py:28:
RemovedInDjango40Warning: force_text() is deprecated in favor of
force_str().

* No need to use unicode strings with Python3

* Remove singledispatch dependency

singledispatch is inluded with Python >= 3.4, no need for external
package.
2020-04-06 13:21:07 +01:00
143 changed files with 7504 additions and 1764 deletions

View File

@ -27,8 +27,8 @@ a github repo, https://repl.it or similar (you can use this template as a starti
* **Please tell us about your environment:**
- Version:
- Platform:
- Version:
- Platform:
* **Other information** (e.g. detailed explanation, stacktraces, related issues, suggestions how to fix, links for us to have context, eg. stackoverflow)

View File

@ -6,21 +6,26 @@ on:
- 'v*'
jobs:
build:
lint:
uses: ./.github/workflows/lint.yml
tests:
uses: ./.github/workflows/tests.yml
release:
runs-on: ubuntu-latest
needs: [lint, tests]
steps:
- uses: actions/checkout@v1
- name: Set up Python 3.8
uses: actions/setup-python@v1
- uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: 3.8
python-version: '3.11'
- name: Build wheel and source tarball
run: |
pip install wheel
python setup.py sdist bdist_wheel
- name: Publish a Python distribution to PyPI
uses: pypa/gh-action-pypi-publish@v1.1.0
uses: pypa/gh-action-pypi-publish@release/v1
with:
user: __token__
password: ${{ secrets.pypi_password }}

View File

@ -1,22 +1,26 @@
name: Lint
on: [push, pull_request]
on:
push:
branches: ["main"]
pull_request:
workflow_call:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v1
- name: Set up Python 3.8
uses: actions/setup-python@v1
- uses: actions/checkout@v3
- name: Set up Python 3.11
uses: actions/setup-python@v4
with:
python-version: 3.8
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install tox
- name: Run lint 💅
- name: Run pre-commit 💅
run: tox
env:
TOXENV: flake8
TOXENV: pre-commit

View File

@ -1,6 +1,10 @@
name: Tests
on: [push, pull_request]
on:
push:
branches: ["main"]
pull_request:
workflow_call:
jobs:
build:
@ -8,16 +12,25 @@ jobs:
strategy:
max-parallel: 4
matrix:
django: ["1.11", "2.2", "3.0", "3.1"]
python-version: ["3.6", "3.7", "3.8"]
include:
- django: "1.11"
python-version: "2.7"
django: ["3.2", "4.2", "5.0", "5.1"]
python-version: ["3.8", "3.9", "3.10", "3.11", "3.12"]
exclude:
- django: "3.2"
python-version: "3.11"
- django: "3.2"
python-version: "3.12"
- django: "5.0"
python-version: "3.8"
- django: "5.0"
python-version: "3.9"
- django: "5.1"
python-version: "3.8"
- django: "5.1"
python-version: "3.9"
steps:
- uses: actions/checkout@v1
- uses: actions/checkout@v3
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v1
uses: actions/setup-python@v4
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
@ -28,4 +41,3 @@ jobs:
run: tox
env:
DJANGO: ${{ matrix.django }}
TOXENV: ${{ matrix.toxenv }}

8
.gitignore vendored
View File

@ -11,6 +11,9 @@ __pycache__/
# Distribution / packaging
.Python
env/
.env/
venv/
.venv/
build/
develop-eggs/
dist/
@ -80,3 +83,8 @@ Session.vim
tags
.tox/
.pytest_cache/
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
.python-version

23
.pre-commit-config.yaml Normal file
View 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
View 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
View 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

View File

@ -33,7 +33,7 @@ make tests
## Opening Pull Requests
Please fork the project and open a pull request against the master branch.
Please fork the project and open a pull request against the `main` branch.
This will trigger a series of test and lint checks.
@ -59,4 +59,4 @@ Then to produce a HTML version of the documentation:
```sh
make html
```
```

View File

@ -3,4 +3,4 @@ recursive-include graphene_django/templates *
recursive-include graphene_django/static *
include examples/cookbook/cookbook/ingredients/fixtures/ingredients.json
include examples/cookbook-plain/cookbook/ingredients/fixtures/ingredients.json
include examples/cookbook-plain/cookbook/ingredients/fixtures/ingredients.json

View File

@ -1,24 +1,24 @@
.PHONY: help
help:
@echo "Please use \`make <target>' where <target> is one of"
@grep -E '^\.PHONY: [a-zA-Z_-]+ .*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = "(: |##)"}; {printf "\033[36m%-30s\033[0m %s\n", $$2, $$3}'
.PHONY: dev-setup ## Install development dependencies
dev-setup:
pip install -e ".[dev]"
python -m pre_commit install
.PHONY: install-dev
install-dev: dev-setup # Alias install-dev -> dev-setup
.PHONY: tests
.PHONY: tests ## Run unit tests
tests:
py.test graphene_django --cov=graphene_django -vv
PYTHONPATH=. pytest graphene_django --cov=graphene_django -vv
.PHONY: test
test: tests # Alias test -> tests
.PHONY: format
.PHONY: format ## Format code
format:
black --exclude "/migrations/" graphene_django examples setup.py
ruff format graphene_django examples setup.py
.PHONY: lint
.PHONY: lint ## Lint code
lint:
flake8 graphene_django examples
ruff graphene_django examples
.PHONY: docs ## Generate docs
docs: dev-setup

155
README.md
View File

@ -1,8 +1,5 @@
# ![Graphene Logo](http://graphene-python.org/favicon.png) Graphene-Django
A [Django](https://www.djangoproject.com/) integration for [Graphene](http://graphene-python.org/).
[![build][build-image]][build-url]
[![pypi][pypi-image]][pypi-url]
[![Anaconda-Server Badge][conda-image]][conda-url]
@ -17,107 +14,137 @@ A [Django](https://www.djangoproject.com/) integration for [Graphene](http://gra
[conda-image]: https://img.shields.io/conda/vn/conda-forge/graphene-django.svg
[conda-url]: https://anaconda.org/conda-forge/graphene-django
[💬 Join the community on Slack](https://join.slack.com/t/graphenetools/shared_invite/enQtOTE2MDQ1NTg4MDM1LTA4Nzk0MGU0NGEwNzUxZGNjNDQ4ZjAwNDJjMjY0OGE1ZDgxZTg4YjM2ZTc4MjE2ZTAzZjE2ZThhZTQzZTkyMmM)
Graphene-Django is an open-source library that provides seamless integration between Django, a high-level Python web framework, and Graphene, a library for building GraphQL APIs. The library allows developers to create GraphQL APIs in Django quickly and efficiently while maintaining a high level of performance.
## Documentation
## Features
[Visit the documentation to get started!](https://docs.graphene-python.org/projects/django/en/latest/)
* Seamless integration with Django models
* Automatic generation of GraphQL schema
* Integration with Django's authentication and permission system
* Easy querying and filtering of data
* Support for Django's pagination system
* Compatible with Django's form and validation system
* Extensive documentation and community support
## Quickstart
## Installation
For installing graphene, just run this command in your shell
To install Graphene-Django, run the following command:
```bash
pip install "graphene-django>=2.0"
```sh
pip install graphene-django
```
### Settings
## Configuration
After installation, add 'graphene_django' to your Django project's `INSTALLED_APPS` list and define the GraphQL schema in your project's settings:
```python
INSTALLED_APPS = (
INSTALLED_APPS = [
# ...
'django.contrib.staticfiles', # Required for GraphiQL
'graphene_django',
)
]
GRAPHENE = {
'SCHEMA': 'app.schema.schema' # Where your Graphene schema lives
'SCHEMA': 'myapp.schema.schema'
}
```
### Urls
## Usage
We need to set up a `GraphQL` endpoint in our Django app, so we can serve the queries.
To use Graphene-Django, create a `schema.py` file in your Django app directory and define your GraphQL types and queries:
```python
from django.urls import path
from graphene_django.views import GraphQLView
urlpatterns = [
# ...
path('graphql', GraphQLView.as_view(graphiql=True)),
]
```
## Examples
Here is a simple Django model:
```python
from django.db import models
class UserModel(models.Model):
name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
```
To create a GraphQL schema for it you simply have to write the following:
```python
from graphene_django import DjangoObjectType
import graphene
from graphene_django import DjangoObjectType
from .models import MyModel
class User(DjangoObjectType):
class MyModelType(DjangoObjectType):
class Meta:
model = UserModel
model = MyModel
class Query(graphene.ObjectType):
users = graphene.List(User)
mymodels = graphene.List(MyModelType)
def resolve_users(self, info):
return UserModel.objects.all()
def resolve_mymodels(self, info, **kwargs):
return MyModel.objects.all()
schema = graphene.Schema(query=Query)
```
Then you can query the schema:
Then, expose the GraphQL API in your Django project's `urls.py` file:
```python
query = '''
query {
users {
name,
lastName
}
}
'''
result = schema.execute(query)
from django.urls import path
from graphene_django.views import GraphQLView
from . import schema
urlpatterns = [
# ...
path('graphql/', GraphQLView.as_view(graphiql=True)), # Given that schema path is defined in GRAPHENE['SCHEMA'] in your settings.py
]
```
To learn more check out the following [examples](examples/):
## Testing
* **Schema with Filtering**: [Cookbook example](examples/cookbook)
* **Relay Schema**: [Starwars Relay example](examples/starwars)
Graphene-Django provides support for testing GraphQL APIs using Django's test client. To create tests, create a `tests.py` file in your Django app directory and write your test cases:
```python
from django.test import TestCase
from graphene_django.utils.testing import GraphQLTestCase
from . import schema
## GraphQL testing clients
- [Firecamp](https://firecamp.io/graphql)
- [GraphiQL](https://github.com/graphql/graphiql)
class MyModelAPITestCase(GraphQLTestCase):
GRAPHENE_SCHEMA = schema.schema
def test_query_all_mymodels(self):
response = self.query(
'''
query {
mymodels {
id
name
}
}
'''
)
self.assertResponseNoErrors(response)
self.assertEqual(len(response.data['mymodels']), MyModel.objects.count())
```
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md)
Contributions to Graphene-Django are always welcome! To get started, check the repository's [issue tracker](https://github.com/graphql-python/graphene-django/issues) and [contribution guidelines](https://github.com/graphql-python/graphene-django/blob/main/CONTRIBUTING.md).
## License
Graphene-Django is released under the [MIT License](https://github.com/graphql-python/graphene-django/blob/main/LICENSE).
## Resources
* [Official GitHub Repository](https://github.com/graphql-python/graphene-django)
* [Graphene Documentation](http://docs.graphene-python.org/en/latest/)
* [Django Documentation](https://docs.djangoproject.com/en/stable/)
* [GraphQL Specification](https://spec.graphql.org/)
* [GraphiQL](https://github.com/graphql/graphiql) - An in-browser IDE for exploring GraphQL APIs
* [Graphene-Django Community](https://spectrum.chat/graphene) - Join the community to discuss questions and share ideas related to Graphene-Django
## Tutorials and Examples
* [Official Graphene-Django Tutorial](https://docs.graphene-python.org/projects/django/en/latest/tutorial-plain/)
* [Building a GraphQL API with Django and Graphene-Django](https://www.howtographql.com/graphql-python/0-introduction/)
* [Real-world example: Django, Graphene, and Relay](https://github.com/graphql-python/swapi-graphene)
## Related Projects
* [Graphene](https://github.com/graphql-python/graphene) - A library for building GraphQL APIs in Python
* [Graphene-SQLAlchemy](https://github.com/graphql-python/graphene-sqlalchemy) - Integration between Graphene and SQLAlchemy, an Object Relational Mapper (ORM) for Python
* [Graphene-File-Upload](https://github.com/lmcgartland/graphene-file-upload) - A package providing an Upload scalar for handling file uploads in Graphene
* [Graphene-Subscriptions](https://github.com/graphql-python/graphene-subscriptions) - A package for adding real-time subscriptions to Graphene-based GraphQL APIs
## Support
If you encounter any issues or have questions regarding Graphene-Django, feel free to [submit an issue](https://github.com/graphql-python/graphene-django/issues/new) on the official GitHub repository. You can also ask for help and share your experiences with the Graphene-Django community on [💬 Discord](https://discord.gg/Fftt273T79)
## Release Notes

View File

@ -1,122 +0,0 @@
Please read
`UPGRADE-v2.0.md <https://github.com/graphql-python/graphene/blob/master/UPGRADE-v2.0.md>`__
to learn how to upgrade to Graphene ``2.0``.
--------------
|Graphene Logo| Graphene-Django |Build Status| |PyPI version| |Coverage Status|
===============================================================================
A `Django <https://www.djangoproject.com/>`__ integration for
`Graphene <http://graphene-python.org/>`__.
Documentation
-------------
`Visit the documentation to get started! <https://docs.graphene-python.org/projects/django/en/latest/>`__
Quickstart
----------
For installing graphene, just run this command in your shell
.. code:: bash
pip install "graphene-django>=2.0"
Settings
~~~~~~~~
.. code:: python
INSTALLED_APPS = (
# ...
'graphene_django',
)
GRAPHENE = {
'SCHEMA': 'app.schema.schema' # Where your Graphene schema lives
}
Urls
~~~~
We need to set up a ``GraphQL`` endpoint in our Django app, so we can
serve the queries.
.. code:: python
from django.conf.urls import url
from graphene_django.views import GraphQLView
urlpatterns = [
# ...
url(r'^graphql$', GraphQLView.as_view(graphiql=True)),
]
Examples
--------
Here is a simple Django model:
.. code:: python
from django.db import models
class UserModel(models.Model):
name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
To create a GraphQL schema for it you simply have to write the
following:
.. code:: python
from graphene_django import DjangoObjectType
import graphene
class User(DjangoObjectType):
class Meta:
model = UserModel
class Query(graphene.ObjectType):
users = graphene.List(User)
@graphene.resolve_only_args
def resolve_users(self):
return UserModel.objects.all()
schema = graphene.Schema(query=Query)
Then you can simply query the schema:
.. code:: python
query = '''
query {
users {
name,
lastName
}
}
'''
result = schema.execute(query)
To learn more check out the following `examples <examples/>`__:
- **Schema with Filtering**: `Cookbook example <examples/cookbook>`__
- **Relay Schema**: `Starwars Relay example <examples/starwars>`__
Contributing
------------
See `CONTRIBUTING.md <CONTRIBUTING.md>`__.
.. |Graphene Logo| image:: http://graphene-python.org/favicon.png
.. |Build Status| image:: https://github.com/graphql-python/graphene-django/workflows/Tests/badge.svg
:target: https://github.com/graphql-python/graphene-django/actions
.. |PyPI version| image:: https://badge.fury.io/py/graphene-django.svg
:target: https://badge.fury.io/py/graphene-django
.. |Coverage Status| image:: https://coveralls.io/repos/graphql-python/graphene-django/badge.svg?branch=master&service=github
:target: https://coveralls.io/github/graphql-python/graphene-django?branch=master

View File

@ -48,6 +48,31 @@ conversely you can use ``exclude`` meta attribute.
exclude = ('published', 'owner')
interfaces = (relay.Node, )
Another pattern is to have a resolve method act as a gatekeeper, returning None
or raising an exception if the client isn't allowed to see the data.
.. code:: python
from graphene import relay
from graphene_django.types import DjangoObjectType
from .models import Post
class PostNode(DjangoObjectType):
class Meta:
model = Post
fields = ('title', 'content', 'owner')
interfaces = (relay.Node, )
def resolve_owner(self, info):
user = info.context.user
if user.is_anonymous:
raise PermissionDenied("Please login")
if not user.is_staff:
return None
return self.owner
Queryset Filtering On Lists
---------------------------
@ -111,6 +136,7 @@ If you are using ``DjangoObjectType`` you can define a custom `get_queryset`.
class PostNode(DjangoObjectType):
class Meta:
model = Post
fields = '__all__'
@classmethod
def get_queryset(cls, queryset, info):
@ -118,6 +144,21 @@ If you are using ``DjangoObjectType`` you can define a custom `get_queryset`.
return queryset.filter(published=True)
return queryset
.. warning::
Defining a custom ``get_queryset`` gives the guaranteed it will be called
when resolving the ``DjangoObjectType``, even through related objects.
Note that because of this, benefits from using ``select_related``
in objects that define a relation to this ``DjangoObjectType`` will be canceled out.
In the case of ``prefetch_related``, the benefits of the optimization will be lost only
if the custom ``get_queryset`` modifies the queryset. For more information about this, refers
to Django documentation about ``prefetch_related``: https://docs.djangoproject.com/en/4.2/ref/models/querysets/#prefetch-related.
If you want to explicitly disable the execution of the custom ``get_queryset`` when resolving,
you can decorate the resolver with `@graphene_django.bypass_get_queryset`. Note that this
can lead to authorization leaks if you are performing authorization checks in the custom
``get_queryset``.
Filtering ID-based Node Access
------------------------------
@ -166,22 +207,13 @@ To restrict users from accessing the GraphQL API page the standard Django LoginR
After this, you can use the new ``PrivateGraphQLView`` in the project's URL Configuration file ``url.py``:
For Django 1.11:
For Django 2.2 and above:
.. code:: python
urlpatterns = [
# some other urls
url(r'^graphql$', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
# some other urls
path('graphql/', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
]
For Django 2.0 and above:
.. code:: python
urlpatterns = [
# some other urls
path('graphql', PrivateGraphQLView.as_view(graphiql=True, schema=schema)),
]
.. _LoginRequiredMixin: https://docs.djangoproject.com/en/1.10/topics/auth/default/#the-loginrequired-mixin
.. _LoginRequiredMixin: https://docs.djangoproject.com/en/dev/topics/auth/default/#the-loginrequired-mixin

View File

@ -60,25 +60,25 @@ source_suffix = ".rst"
master_doc = "index"
# General information about the project.
project = u"Graphene Django"
copyright = u"Graphene 2017"
author = u"Syrus Akbary"
project = "Graphene Django"
copyright = "Graphene 2017"
author = "Syrus Akbary"
# The version info for the project you're documenting, acts as replacement for
# |version| and |release|, also used in various other places throughout the
# built documents.
#
# The short X.Y version.
version = u"1.0"
version = "1.0"
# The full version, including alpha/beta/rc tags.
release = u"1.0.dev"
release = "1.0.dev"
# The language for content autogenerated by Sphinx. Refer to documentation
# for a list of supported languages.
#
# This is also used if you do content translation via gettext catalogs.
# Usually you set "language" from the command line for these cases.
language = None
# language = None
# There are two options for replacing |today|: either, you set today to some
# non-false value, then it is used:
@ -276,7 +276,7 @@ latex_elements = {
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
(master_doc, "Graphene.tex", u"Graphene Documentation", u"Syrus Akbary", "manual")
(master_doc, "Graphene.tex", "Graphene Documentation", "Syrus Akbary", "manual")
]
# The name of an image file (relative to this directory) to place at the top of
@ -317,7 +317,7 @@ latex_documents = [
# One entry per manual page. List of tuples
# (source start file, name, description, authors, manual section).
man_pages = [
(master_doc, "graphene_django", u"Graphene Django Documentation", [author], 1)
(master_doc, "graphene_django", "Graphene Django Documentation", [author], 1)
]
# If true, show URL addresses after external links.
@ -334,7 +334,7 @@ texinfo_documents = [
(
master_doc,
"Graphene-Django",
u"Graphene Django Documentation",
"Graphene Django Documentation",
author,
"Graphene Django",
"One line description of project.",
@ -445,4 +445,7 @@ epub_exclude_files = ["search.html"]
# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {"https://docs.python.org/": None}
intersphinx_mapping = {
# "https://docs.python.org/": None,
"python": ("https://docs.python.org/", None),
}

View File

@ -4,7 +4,7 @@ Django Debug Middleware
You can debug your GraphQL queries in a similar way to
`django-debug-toolbar <https://django-debug-toolbar.readthedocs.org/>`__,
but outputting in the results in GraphQL response as fields, instead of
the graphical HTML interface.
the graphical HTML interface. Exceptions with their stack traces are also exposed.
For that, you will need to add the plugin in your graphene schema.
@ -63,6 +63,10 @@ the GraphQL request, like:
sql {
rawSql
}
exceptions {
message
stack
}
}
}

View File

@ -80,4 +80,6 @@ published and have a title:
DjangoConnectionField
---------------------
*TODO*
``DjangoConnectionField`` acts similarly to ``DjangoListField`` but returns a
paginated connection following the `relay spec <https://relay.dev/graphql/connections.htm>`__
The field supports the following arguments: `first`, `last`, `offset`, `after` & `before`.

View File

@ -1,10 +1,9 @@
Filtering
=========
Graphene-Django integrates with
`django-filter <https://django-filter.readthedocs.io/en/master/>`__ (2.x for
Python 3 or 1.x for Python 2) to provide filtering of results. See the `usage
documentation <https://django-filter.readthedocs.io/en/master/guide/usage.html#the-filter>`__
Graphene integrates with
`django-filter <https://django-filter.readthedocs.io/en/stable/>`__ to provide filtering of results.
See the `usage documentation <https://django-filter.readthedocs.io/en/stable/guide/usage.html#the-filter>`__
for details on the format for ``filter_fields``.
This filtering is automatically available when implementing a ``relay.Node``.
@ -16,7 +15,7 @@ You will need to install it manually, which can be done as follows:
# You'll need to install django-filter
pip install django-filter>=2
After installing ``django-filter`` you'll need to add the application in the ``settings.py`` file:
.. code:: python
@ -27,7 +26,7 @@ After installing ``django-filter`` you'll need to add the application in the ``s
]
Note: The techniques below are demoed in the `cookbook example
app <https://github.com/graphql-python/graphene-django/tree/master/examples/cookbook>`__.
app <https://github.com/graphql-python/graphene-django/tree/main/examples/cookbook>`__.
Filterable fields
-----------------
@ -35,7 +34,7 @@ Filterable fields
The ``filter_fields`` parameter is used to specify the fields which can
be filtered upon. The value specified here is passed directly to
``django-filter``, so see the `filtering
documentation <https://django-filter.readthedocs.io/en/master/guide/usage.html#the-filter>`__
documentation <https://django-filter.readthedocs.io/en/main/guide/usage.html#the-filter>`__
for full details on the range of options available.
For example:
@ -46,6 +45,7 @@ For example:
class Meta:
# Assume you have an Animal model defined with the following fields
model = Animal
fields = '__all__'
filter_fields = ['name', 'genus', 'is_domesticated']
interfaces = (relay.Node, )
@ -76,6 +76,7 @@ You can also make more complex lookup types available:
class AnimalNode(DjangoObjectType):
class Meta:
model = Animal
fields = '__all__'
# Provide more complex lookup types
filter_fields = {
'name': ['exact', 'icontains', 'istartswith'],
@ -117,6 +118,7 @@ create your own ``FilterSet``. You can pass it directly as follows:
class Meta:
# Assume you have an Animal model defined with the following fields
model = Animal
fields = '__all__'
filter_fields = ['name', 'genus', 'is_domesticated']
interfaces = (relay.Node, )
@ -124,6 +126,15 @@ create your own ``FilterSet``. You can pass it directly as follows:
class AnimalFilter(django_filters.FilterSet):
# Do case-insensitive lookups on 'name'
name = django_filters.CharFilter(lookup_expr=['iexact'])
# Allow multiple genera to be selected at once
genera = django_filters.MultipleChoiceFilter(
field_name='genus',
choices=(
('Canis', 'Canis'),
('Panthera', 'Panthera'),
('Seahorse', 'Seahorse')
)
)
class Meta:
model = Animal
@ -136,6 +147,22 @@ create your own ``FilterSet``. You can pass it directly as follows:
all_animals = DjangoFilterConnectionField(AnimalNode,
filterset_class=AnimalFilter)
If you were interested in selecting all dogs and cats, you might query as follows:
.. code::
query {
allAnimals(genera: ["Canis", "Panthera"]) {
edges {
node {
id,
name
}
}
}
}
You can also specify the ``FilterSet`` class using the ``filterset_class``
parameter when defining your ``DjangoObjectType``, however, this can't be used
in unison with the ``filter_fields`` parameter:
@ -155,6 +182,7 @@ in unison with the ``filter_fields`` parameter:
class AnimalNode(DjangoObjectType):
class Meta:
model = Animal
fields = '__all__'
filterset_class = AnimalFilter
interfaces = (relay.Node, )
@ -163,7 +191,8 @@ in unison with the ``filter_fields`` parameter:
animal = relay.Node.Field(AnimalNode)
all_animals = DjangoFilterConnectionField(AnimalNode)
The context argument is passed on as the `request argument <http://django-filter.readthedocs.io/en/master/guide/usage.html#request-based-filtering>`__
The context argument is passed on as the `request argument <http://django-filter.readthedocs.io/en/main/guide/usage.html#request-based-filtering>`__
in a ``django_filters.FilterSet`` instance. You can use this to customize your
filters to be context-dependent. We could modify the ``AnimalFilter`` above to
pre-filter animals owned by the authenticated user (set in ``context.user``).
@ -211,6 +240,7 @@ Extend the tuple of fields if you want to order by more than one field.
class Meta:
name = 'Group'
model = GroupModel
fields = '__all__'
interfaces = (relay.Node,)
def resolve_users(self, info, **kwargs):
@ -228,3 +258,86 @@ with this set up, you can now order the users under group:
}
}
}
PostgreSQL `ArrayField`
-----------------------
Graphene provides an easy to implement filters on `ArrayField` as they are not natively supported by django_filters:
.. code:: python
from django.db import models
from django_filters import FilterSet, OrderingFilter
from graphene_django.filter import ArrayFilter
class Event(models.Model):
name = models.CharField(max_length=50)
tags = ArrayField(models.CharField(max_length=50))
class EventFilterSet(FilterSet):
class Meta:
model = Event
fields = {
"name": ["exact", "contains"],
}
tags__contains = ArrayFilter(field_name="tags", lookup_expr="contains")
tags__overlap = ArrayFilter(field_name="tags", lookup_expr="overlap")
tags = ArrayFilter(field_name="tags", lookup_expr="exact")
class EventType(DjangoObjectType):
class Meta:
model = Event
interfaces = (Node,)
fields = "__all__"
filterset_class = EventFilterSet
with this set up, you can now filter events by tags:
.. code::
query {
events(tags_Overlap: ["concert", "festival"]) {
name
}
}
`TypedFilter`
-------------
Sometimes the automatic detection of the filter input type is not satisfactory for what you are trying to achieve.
You can then explicitly specify the input type you want for your filter by using a `TypedFilter`:
.. code:: python
from django.db import models
from django_filters import FilterSet, OrderingFilter
import graphene
from graphene_django.filter import TypedFilter
class Event(models.Model):
name = models.CharField(max_length=50)
class EventFilterSet(FilterSet):
class Meta:
model = Event
fields = {
"name": ["exact", "contains"],
}
only_first = TypedFilter(input_type=graphene.Boolean, method="only_first_filter")
def only_first_filter(self, queryset, _name, value):
if value:
return queryset[:1]
else:
return queryset
class EventType(DjangoObjectType):
class Meta:
model = Event
interfaces = (Node,)
fields = "__all__"
filterset_class = EventFilterSet

View File

@ -33,5 +33,6 @@ For more advanced use, check out the Relay tutorial.
authorization
debug
introspection
validation
testing
settings

View File

@ -8,7 +8,7 @@ Requirements
Graphene-Django currently supports the following versions of Django:
* >= Django 1.11
* >= Django 2.2
Installation
------------
@ -32,19 +32,7 @@ Add ``graphene_django`` to the ``INSTALLED_APPS`` in the ``settings.py`` file of
We need to add a ``graphql`` URL to the ``urls.py`` of your Django project:
For Django 1.11:
.. code:: python
from django.conf.urls import url
from graphene_django.views import GraphQLView
urlpatterns = [
# ...
url(r"graphql", GraphQLView.as_view(graphiql=True)),
]
For Django 2.0 and above:
For Django 2.2 and above:
.. code:: python

View File

@ -57,9 +57,9 @@ specify the parameters in your settings.py:
.. code:: python
GRAPHENE = {
'SCHEMA': 'tutorial.quickstart.schema',
'SCHEMA_OUTPUT': 'data/schema.json', # defaults to schema.json,
'SCHEMA_INDENT': 2, # Defaults to None (displays all data on a single line)
'SCHEMA': 'tutorial.quickstart.schema',
'SCHEMA_OUTPUT': 'data/schema.json', # defaults to schema.json,
'SCHEMA_INDENT': 2, # Defaults to None (displays all data on a single line)
}

View File

@ -25,6 +25,7 @@ Simple example
class QuestionType(DjangoObjectType):
class Meta:
model = Question
fields = '__all__'
class QuestionMutation(graphene.Mutation):
@ -91,6 +92,7 @@ DjangoModelFormMutation
class PetType(DjangoObjectType):
class Meta:
model = Pet
fields = '__all__'
class PetMutation(DjangoModelFormMutation):
pet = Field(PetType)
@ -123,6 +125,55 @@ to change how the form is saved or to return a different Graphene object type.
If the form is *not* valid then a list of errors will be returned. These errors have two fields: ``field``, a string
containing the name of the invalid form field, and ``messages``, a list of strings with the validation messages.
DjangoFormInputObjectType
~~~~~~~~~~~~~~~~~~~~~~~~~
``DjangoFormInputObjectType`` is used in mutations to create input fields by **using django form** to retrieve input data structure from it. This can be helpful in situations where you need to pass data to several django forms in one mutation.
.. code:: python
from graphene_django.forms.types import DjangoFormInputObjectType
class PetFormInput(DjangoFormInputObjectType):
# any other fields can be placed here as well as
# other djangoforminputobjects and intputobjects
class Meta:
form_class = PetForm
object_type = PetType
class QuestionFormInput(DjangoFormInputObjectType)
class Meta:
form_class = QuestionForm
object_type = QuestionType
class SeveralFormsInputData(graphene.InputObjectType):
pet = PetFormInput(required=True)
question = QuestionFormInput(required=True)
class SomeSophisticatedMutation(graphene.Mutation):
class Arguments:
data = SeveralFormsInputData(required=True)
@staticmethod
def mutate(_root, _info, data):
pet_form_inst = PetForm(data=data.pet)
question_form_inst = QuestionForm(data=data.question)
if pet_form_inst.is_valid():
pet_model_instance = pet_form_inst.save(commit=False)
if question_form_inst.is_valid():
question_model_instance = question_form_inst.save(commit=False)
# ...
Additional to **InputObjectType** ``Meta`` class attributes:
* ``form_class`` is required and should be equal to django form class.
* ``object_type`` is not required and used to enable convertion of enum values back to original if model object type ``convert_choices_to_enum`` ``Meta`` class attribute is not set to ``False``. Any data field, which have choices in django, with value ``A_1`` (for example) from client will be automatically converted to ``1`` in mutation data.
* ``add_id_field_name`` is used to specify `id` field name (not required, by default equal to ``id``)
* ``add_id_field_type`` is used to specify `id` field type (not required, default is ``graphene.ID``)
Django REST Framework
---------------------

View File

@ -52,6 +52,9 @@ all fields that should be exposed using the fields attribute.
This will make it less likely to result in unintentionally exposing data when
your models change.
Setting neither ``fields`` nor ``exclude`` is deprecated and will raise a warning, you should at least explicitly make
``DjangoObjectType`` include all fields in the model as described below.
``fields``
~~~~~~~~~~
@ -148,7 +151,7 @@ For example the following ``Model`` and ``DjangoObjectType``:
Results in the following GraphQL schema definition:
.. code::
.. code:: graphql
type Pet {
id: ID!
@ -175,7 +178,7 @@ You can disable this automatic conversion by setting
fields = ("id", "kind",)
convert_choices_to_enum = False
.. code::
.. code:: graphql
type Pet {
id: ID!
@ -258,6 +261,7 @@ Use this to control filtering on the ObjectType level instead of the Query objec
class QuestionType(DjangoObjectType):
class Meta:
model = Question
fields = "__all__"
@classmethod
def get_queryset(cls, queryset, info):
@ -309,7 +313,7 @@ Additionally, Resolvers will receive **any arguments declared in the field defin
bar=graphene.Int()
)
def resolve_question(root, info, foo, bar):
def resolve_question(root, info, foo=None, bar=None):
# If `foo` or `bar` are declared in the GraphQL query they will be here, else None.
return Question.objects.filter(foo=foo, bar=bar).first()
@ -332,12 +336,12 @@ of Django's ``HTTPRequest`` in your resolve methods, such as checking for authen
class Query(graphene.ObjectType):
questions = graphene.List(QuestionType)
def resolve_questions(root, info):
# See if a user is authenticated
if info.context.user.is_authenticated():
return Question.objects.all()
else:
return Question.objects.none()
def resolve_questions(root, info):
# See if a user is authenticated
if info.context.user.is_authenticated():
return Question.objects.all()
else:
return Question.objects.none()
DjangoObjectTypes
@ -414,29 +418,29 @@ the core graphene pages for more information on customizing the Relay experience
You can now execute queries like:
.. code:: python
.. code:: graphql
{
questions (first: 2, after: "YXJyYXljb25uZWN0aW9uOjEwNQ==") {
pageInfo {
startCursor
endCursor
hasNextPage
hasPreviousPage
startCursor
endCursor
hasNextPage
hasPreviousPage
}
edges {
cursor
node {
id
question_text
}
cursor
node {
id
question_text
}
}
}
}
Which returns:
.. code:: python
.. code:: json
{
"data": {

View File

@ -1,4 +1,5 @@
Sphinx==1.5.3
sphinx-autobuild==0.7.1
Sphinx==7.0.0
sphinx-autobuild==2021.3.14
pygments-graphql-lexer==0.1.0
# Docs template
http://graphene-python.org/sphinx_graphene_theme.zip

View File

@ -1,58 +1,57 @@
import graphene
import graphene
from graphene_django.types import DjangoObjectType
from graphene_django.types import DjangoObjectType
from cookbook.ingredients.models import Category, Ingredient
from cookbook.ingredients.models import Category, Ingredient
class CategoryType(DjangoObjectType):
class Meta:
model = Category
class CategoryType(DjangoObjectType):
class Meta:
model = Category
fields = "__all__"
class IngredientType(DjangoObjectType):
class Meta:
model = Ingredient
class IngredientType(DjangoObjectType):
class Meta:
model = Ingredient
fields = "__all__"
class Query(object):
category = graphene.Field(CategoryType,
id=graphene.Int(),
name=graphene.String())
all_categories = graphene.List(CategoryType)
class Query:
category = graphene.Field(CategoryType, id=graphene.Int(), name=graphene.String())
all_categories = graphene.List(CategoryType)
ingredient = graphene.Field(
IngredientType, id=graphene.Int(), name=graphene.String()
)
all_ingredients = graphene.List(IngredientType)
ingredient = graphene.Field(IngredientType,
id=graphene.Int(),
name=graphene.String())
all_ingredients = graphene.List(IngredientType)
def resolve_all_categories(self, info, **kwargs):
return Category.objects.all()
def resolve_all_categories(self, info, **kwargs):
return Category.objects.all()
def resolve_all_ingredients(self, info, **kwargs):
return Ingredient.objects.all()
def resolve_all_ingredients(self, info, **kwargs):
return Ingredient.objects.all()
def resolve_category(self, info, **kwargs):
id = kwargs.get("id")
name = kwargs.get("name")
def resolve_category(self, info, **kwargs):
id = kwargs.get('id')
name = kwargs.get('name')
if id is not None:
return Category.objects.get(pk=id)
if id is not None:
return Category.objects.get(pk=id)
if name is not None:
return Category.objects.get(name=name)
if name is not None:
return Category.objects.get(name=name)
return None
return None
def resolve_ingredient(self, info, **kwargs):
id = kwargs.get("id")
name = kwargs.get("name")
def resolve_ingredient(self, info, **kwargs):
id = kwargs.get('id')
name = kwargs.get('name')
if id is not None:
return Ingredient.objects.get(pk=id)
if id is not None:
return Ingredient.objects.get(pk=id)
if name is not None:
return Ingredient.objects.get(name=name)
if name is not None:
return Ingredient.objects.get(name=name)
return None
return None

View File

@ -6,7 +6,7 @@ Graphene-Django can be customised using settings. This page explains each settin
Usage
-----
Add settings to your Django project by creating a Dictonary with name ``GRAPHENE`` in the project's ``settings.py``:
Add settings to your Django project by creating a Dictionary with name ``GRAPHENE`` in the project's ``settings.py``:
.. code:: python
@ -142,10 +142,19 @@ Default: ``False``
# ]
``DJANGO_CHOICE_FIELD_ENUM_V3_NAMING``
``DJANGO_CHOICE_FIELD_ENUM_CONVERT``
--------------------------------------
Set to ``True`` to use the new naming format for the auto generated Enum types from Django choice fields. The new format looks like this: ``{app_label}{object_name}{field_name}Choices``
When set to ``True`` Django choice fields are automatically converted into Enum types.
Can be disabled globally by setting it to ``False``.
Default: ``True``
``DJANGO_CHOICE_FIELD_ENUM_V2_NAMING``
--------------------------------------
Set to ``True`` to use the old naming format for the auto generated Enum types from Django choice fields. The old format looks like this: ``{object_name}_{field_name}``
Default: ``False``
@ -155,7 +164,7 @@ Default: ``False``
Define the path of a function that takes the Django choice field and returns a string to completely customise the naming for the Enum type.
If set to a function then the ``DJANGO_CHOICE_FIELD_ENUM_V3_NAMING`` setting is ignored.
If set to a function then the ``DJANGO_CHOICE_FIELD_ENUM_V2_NAMING`` setting is ignored.
Default: ``None``
@ -189,7 +198,7 @@ Default: ``None``
``GRAPHIQL_HEADER_EDITOR_ENABLED``
---------------------
----------------------------------
GraphiQL starting from version 1.0.0 allows setting custom headers in similar fashion to query variables.
@ -197,9 +206,6 @@ Set to ``False`` if you want to disable GraphiQL headers editor tab for some rea
This setting is passed to ``headerEditorEnabled`` GraphiQL options, for details refer to GraphiQLDocs_.
.. _GraphiQLDocs: https://github.com/graphql/graphiql/tree/main/packages/graphiql#options
Default: ``True``
.. code:: python
@ -207,3 +213,79 @@ Default: ``True``
GRAPHENE = {
'GRAPHIQL_HEADER_EDITOR_ENABLED': True,
}
``TESTING_ENDPOINT``
--------------------
Define the graphql endpoint url used for the `GraphQLTestCase` class.
Default: ``/graphql``
.. code:: python
GRAPHENE = {
'TESTING_ENDPOINT': '/customEndpoint'
}
``GRAPHIQL_SHOULD_PERSIST_HEADERS``
-----------------------------------
Set to ``True`` if you want to persist GraphiQL headers after refreshing the page.
This setting is passed to ``shouldPersistHeaders`` GraphiQL options, for details refer to GraphiQLDocs_.
Default: ``False``
.. code:: python
GRAPHENE = {
'GRAPHIQL_SHOULD_PERSIST_HEADERS': False,
}
``GRAPHIQL_INPUT_VALUE_DEPRECATION``
------------------------------------
Set to ``True`` if you want GraphiQL to show any deprecated fields on input object types' docs.
For example, having this schema:
.. code:: python
class MyMutationInputType(graphene.InputObjectType):
old_field = graphene.String(deprecation_reason="You should now use 'newField' instead.")
new_field = graphene.String()
class MyMutation(graphene.Mutation):
class Arguments:
input = types.MyMutationInputType()
GraphiQL will add a ``Show Deprecated Fields`` button to toggle information display on ``oldField`` and its deprecation
reason. Otherwise, you would get neither a button nor any information at all on ``oldField``.
This setting is passed to ``inputValueDeprecation`` GraphiQL options, for details refer to GraphiQLDocs_.
Default: ``False``
.. code:: python
GRAPHENE = {
'GRAPHIQL_INPUT_VALUE_DEPRECATION': False,
}
.. _GraphiQLDocs: https://graphiql-test.netlify.app/typedoc/modules/graphiql_react#graphiqlprovider-2
``MAX_VALIDATION_ERRORS``
------------------------------------
In case ``validation_rules`` are provided to ``GraphQLView``, if this is set to a non-negative ``int`` value,
``graphql.validation.validate`` will stop validation after this number of errors has been reached.
If not set or set to ``None``, the maximum number of errors will follow ``graphql.validation.validate`` default
*i.e.* 100.
Default: ``None``

View File

@ -6,7 +6,8 @@ Using unittest
If you want to unittest your API calls derive your test case from the class `GraphQLTestCase`.
Your endpoint is set through the `GRAPHQL_URL` attribute on `GraphQLTestCase`. The default endpoint is `GRAPHQL_URL = "/graphql/"`.
The default endpoint for testing is `/graphql`. You can override this in the `settings <https://docs.graphene-python.org/projects/django/en/latest/settings/#testing-endpoint>`__.
Usage:
@ -27,7 +28,7 @@ Usage:
}
}
''',
op_name='myModel'
operation_name='myModel'
)
content = json.loads(response.content)
@ -48,7 +49,7 @@ Usage:
}
}
''',
op_name='myModel',
operation_name='myModel',
variables={'id': 1}
)
@ -72,7 +73,42 @@ Usage:
}
}
''',
op_name='myMutation',
operation_name='myMutation',
input_data={'my_field': 'foo', 'other_field': 'bar'}
)
# This validates the status code and if you get errors
self.assertResponseNoErrors(response)
# Add some more asserts if you like
...
For testing mutations that are executed within a transaction you should subclass `GraphQLTransactionTestCase`
Usage:
.. code:: python
import json
from graphene_django.utils.testing import GraphQLTransactionTestCase
class MyFancyTransactionTestCase(GraphQLTransactionTestCase):
def test_some_mutation_that_executes_within_a_transaction(self):
response = self.query(
'''
mutation myMutation($input: MyMutationInput!) {
myMutation(input: $input) {
my-model {
id
name
}
}
}
''',
operation_name='myMutation',
input_data={'my_field': 'foo', 'other_field': 'bar'}
)
@ -112,7 +148,7 @@ To use pytest define a simple fixture using the query helper below
}
}
''',
op_name='myModel'
operation_name='myModel'
)
content = json.loads(response.content)

View File

@ -35,6 +35,7 @@ Now sync your database for the first time:
.. code:: bash
cd ..
python manage.py migrate
Let's create a few simple models...
@ -77,6 +78,18 @@ Add ingredients as INSTALLED_APPS:
"cookbook.ingredients",
]
Make sure the app name in ``cookbook.ingredients.apps.IngredientsConfig`` is set to ``cookbook.ingredients``.
.. code:: python
# cookbook/ingredients/apps.py
from django.apps import AppConfig
class IngredientsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'cookbook.ingredients'
Don't forget to create & run migrations:
@ -91,7 +104,7 @@ Load some test data
Now is a good time to load up some test data. The easiest option will be
to `download the
ingredients.json <https://raw.githubusercontent.com/graphql-python/graphene-django/master/examples/cookbook/cookbook/ingredients/fixtures/ingredients.json>`__
ingredients.json <https://raw.githubusercontent.com/graphql-python/graphene-django/main/examples/cookbook/cookbook/ingredients/fixtures/ingredients.json>`__
fixture and place it in
``cookbook/ingredients/fixtures/ingredients.json``. You can then run the
following:

View File

@ -7,12 +7,12 @@ Graphene has a number of additional features that are designed to make
working with Django *really simple*.
Note: The code in this quickstart is pulled from the `cookbook example
app <https://github.com/graphql-python/graphene-django/tree/master/examples/cookbook>`__.
app <https://github.com/graphql-python/graphene-django/tree/main/examples/cookbook>`__.
A good idea is to check the following things first:
* `Graphene Relay documentation <http://docs.graphene-python.org/en/latest/relay/>`__
* `GraphQL Relay Specification <https://facebook.github.io/relay/docs/en/graphql-server-specification.html>`__
* `GraphQL Relay Specification <https://relay.dev/docs/guides/graphql-server-specification/>`__
Setup the Django project
------------------------
@ -70,7 +70,7 @@ Let's get started with these models:
class Ingredient(models.Model):
name = models.CharField(max_length=100)
notes = models.TextField()
category = models.ForeignKey(Category, related_name='ingredients')
category = models.ForeignKey(Category, related_name='ingredients', on_delete=models.CASCADE)
def __str__(self):
return self.name
@ -87,7 +87,7 @@ Load some test data
Now is a good time to load up some test data. The easiest option will be
to `download the
ingredients.json <https://raw.githubusercontent.com/graphql-python/graphene-django/master/examples/cookbook/cookbook/ingredients/fixtures/ingredients.json>`__
ingredients.json <https://raw.githubusercontent.com/graphql-python/graphene-django/main/examples/cookbook/cookbook/ingredients/fixtures/ingredients.json>`__
fixture and place it in
``cookbook/ingredients/fixtures/ingredients.json``. You can then run the
following:
@ -132,6 +132,7 @@ Create ``cookbook/ingredients/schema.py`` and type the following:
class CategoryNode(DjangoObjectType):
class Meta:
model = Category
fields = '__all__'
filter_fields = ['name', 'ingredients']
interfaces = (relay.Node, )
@ -139,6 +140,7 @@ Create ``cookbook/ingredients/schema.py`` and type the following:
class IngredientNode(DjangoObjectType):
class Meta:
model = Ingredient
fields = '__all__'
# Allow for some more advanced filtering here
filter_fields = {
'name': ['exact', 'icontains', 'istartswith'],
@ -149,7 +151,7 @@ Create ``cookbook/ingredients/schema.py`` and type the following:
interfaces = (relay.Node, )
class Query(graphene.ObjectType):
class Query(ObjectType):
category = relay.Node.Field(CategoryNode)
all_categories = DjangoFilterConnectionField(CategoryNode)
@ -279,7 +281,7 @@ from the command line.
$ python ./manage.py runserver
Performing system checks...
Django version 1.11, using settings 'cookbook.settings'
Django version 3.1.7, using settings 'cookbook.settings'
Starting development server at http://127.0.0.1:8000/
Quit the server with CONTROL-C.

29
docs/validation.rst Normal file
View 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()),
]

View File

@ -14,7 +14,7 @@ whole Graphene repository:
```bash
# Get the example project code
git clone https://github.com/graphql-python/graphene-django.git
cd graphene-django/examples/cookbook
cd graphene-django/examples/cookbook-plain
```
It is good idea (but not required) to create a virtual environment
@ -62,3 +62,12 @@ Now head on over to
and run some queries!
(See the [Graphene-Django Tutorial](http://docs.graphene-python.org/projects/django/en/latest/tutorial-plain/#testing-our-graphql-schema)
for some example queries)
Testing local graphene-django changes
-------------------------------------
In `requirements.txt`, replace the entire `graphene-django=...` line with the following (so that we install the local version instead of the one from PyPI):
```
../../ # graphene-django
```

View File

@ -1 +1,52 @@
[{"model": "ingredients.category", "pk": 1, "fields": {"name": "Dairy"}}, {"model": "ingredients.category", "pk": 2, "fields": {"name": "Meat"}}, {"model": "ingredients.ingredient", "pk": 1, "fields": {"name": "Eggs", "notes": "Good old eggs", "category": 1}}, {"model": "ingredients.ingredient", "pk": 2, "fields": {"name": "Milk", "notes": "Comes from a cow", "category": 1}}, {"model": "ingredients.ingredient", "pk": 3, "fields": {"name": "Beef", "notes": "Much like milk, this comes from a cow", "category": 2}}, {"model": "ingredients.ingredient", "pk": 4, "fields": {"name": "Chicken", "notes": "Definitely doesn't come from a cow", "category": 2}}]
[
{
"fields": {
"name": "Dairy"
},
"model": "ingredients.category",
"pk": 1
},
{
"fields": {
"name": "Meat"
},
"model": "ingredients.category",
"pk": 2
},
{
"fields": {
"category": 1,
"name": "Eggs",
"notes": "Good old eggs"
},
"model": "ingredients.ingredient",
"pk": 1
},
{
"fields": {
"category": 1,
"name": "Milk",
"notes": "Comes from a cow"
},
"model": "ingredients.ingredient",
"pk": 2
},
{
"fields": {
"category": 2,
"name": "Beef",
"notes": "Much like milk, this comes from a cow"
},
"model": "ingredients.ingredient",
"pk": 3
},
{
"fields": {
"category": 2,
"name": "Chicken",
"notes": "Definitely doesn't come from a cow"
},
"model": "ingredients.ingredient",
"pk": 4
}
]

View File

@ -1,33 +1,52 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9 on 2015-12-04 18:15
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
dependencies = []
operations = [
migrations.CreateModel(
name='Category',
name="Category",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100)),
],
),
migrations.CreateModel(
name='Ingredient',
name="Ingredient",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('notes', models.TextField()),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredients', to='ingredients.Category')),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100)),
("notes", models.TextField()),
(
"category",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ingredients",
to="ingredients.Category",
),
),
],
),
]

View File

@ -1,20 +1,17 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9 on 2016-11-04 00:50
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ingredients', '0001_initial'),
("ingredients", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name='ingredient',
name='notes',
model_name="ingredient",
name="notes",
field=models.TextField(blank=True, null=True),
),
]

View File

@ -4,14 +4,13 @@ from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('ingredients', '0002_auto_20161104_0050'),
("ingredients", "0002_auto_20161104_0050"),
]
operations = [
migrations.AlterModelOptions(
name='category',
options={'verbose_name_plural': 'Categories'},
name="category",
options={"verbose_name_plural": "Categories"},
),
]

View File

@ -7,14 +7,16 @@ from .models import Category, Ingredient
class CategoryType(DjangoObjectType):
class Meta:
model = Category
fields = "__all__"
class IngredientType(DjangoObjectType):
class Meta:
model = Ingredient
fields = "__all__"
class Query(object):
class Query:
category = graphene.Field(CategoryType, id=graphene.Int(), name=graphene.String())
all_categories = graphene.List(CategoryType)

View File

@ -1,36 +1,69 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9 on 2015-12-04 18:20
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('ingredients', '0001_initial'),
("ingredients", "0001_initial"),
]
operations = [
migrations.CreateModel(
name='Recipe',
name="Recipe",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=100)),
('instructions', models.TextField()),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=100)),
("instructions", models.TextField()),
],
),
migrations.CreateModel(
name='RecipeIngredient',
name="RecipeIngredient",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.FloatField()),
('unit', models.CharField(choices=[('kg', 'Kilograms'), ('l', 'Litres'), ('', 'Units')], max_length=20)),
('ingredient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='used_by', to='ingredients.Ingredient')),
('recipes', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='amounts', to='recipes.Recipe')),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("amount", models.FloatField()),
(
"unit",
models.CharField(
choices=[("kg", "Kilograms"), ("l", "Litres"), ("", "Units")],
max_length=20,
),
),
(
"ingredient",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="used_by",
to="ingredients.Ingredient",
),
),
(
"recipes",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="amounts",
to="recipes.Recipe",
),
),
],
),
]

View File

@ -1,25 +1,30 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9 on 2016-11-04 01:06
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recipes', '0001_initial'),
("recipes", "0001_initial"),
]
operations = [
migrations.RenameField(
model_name='recipeingredient',
old_name='recipes',
new_name='recipe',
model_name="recipeingredient",
old_name="recipes",
new_name="recipe",
),
migrations.AlterField(
model_name='recipeingredient',
name='unit',
field=models.CharField(choices=[(b'unit', b'Units'), (b'kg', b'Kilograms'), (b'l', b'Litres'), (b'st', b'Shots')], max_length=20),
model_name="recipeingredient",
name="unit",
field=models.CharField(
choices=[
(b"unit", b"Units"),
(b"kg", b"Kilograms"),
(b"l", b"Litres"),
(b"st", b"Shots"),
],
max_length=20,
),
),
]

View File

@ -4,15 +4,22 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recipes', '0002_auto_20161104_0106'),
("recipes", "0002_auto_20161104_0106"),
]
operations = [
migrations.AlterField(
model_name='recipeingredient',
name='unit',
field=models.CharField(choices=[('unit', 'Units'), ('kg', 'Kilograms'), ('l', 'Litres'), ('st', 'Shots')], max_length=20),
model_name="recipeingredient",
name="unit",
field=models.CharField(
choices=[
("unit", "Units"),
("kg", "Kilograms"),
("l", "Litres"),
("st", "Shots"),
],
max_length=20,
),
),
]

View File

@ -7,14 +7,16 @@ from .models import Recipe, RecipeIngredient
class RecipeType(DjangoObjectType):
class Meta:
model = Recipe
fields = "__all__"
class RecipeIngredientType(DjangoObjectType):
class Meta:
model = RecipeIngredient
fields = "__all__"
class Query(object):
class Query:
recipe = graphene.Field(RecipeType, id=graphene.Int(), title=graphene.String())
all_recipes = graphene.List(RecipeType)

View File

@ -1,8 +1,8 @@
import graphene
from graphene_django.debug import DjangoDebug
import cookbook.ingredients.schema
import cookbook.recipes.schema
import graphene
from graphene_django.debug import DjangoDebug
class Query(

View File

@ -5,10 +5,10 @@ Django settings for cookbook project.
Generated by 'django-admin startproject' using Django 1.9.
For more information on this file, see
https://docs.djangoproject.com/en/1.9/topics/settings/
https://docs.djangoproject.com/en/3.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/1.9/ref/settings/
https://docs.djangoproject.com/en/3.2/ref/settings/
"""
import os
@ -18,7 +18,7 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/1.9/howto/deployment/checklist/
# See https://docs.djangoproject.com/en/3.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = "_$=$%eqxk$8ss4n7mtgarw^5$8^d5+c83!vwatr@i_81myb=e4"
@ -81,7 +81,7 @@ WSGI_APPLICATION = "cookbook.wsgi.application"
# Database
# https://docs.djangoproject.com/en/1.9/ref/settings/#databases
# https://docs.djangoproject.com/en/3.2/ref/settings/#databases
DATABASES = {
"default": {
@ -90,9 +90,11 @@ DATABASES = {
}
}
# https://docs.djangoproject.com/en/3.2/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.AutoField"
# Password validation
# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
@ -105,7 +107,7 @@ AUTH_PASSWORD_VALIDATORS = [
# Internationalization
# https://docs.djangoproject.com/en/1.9/topics/i18n/
# https://docs.djangoproject.com/en/3.2/topics/i18n/
LANGUAGE_CODE = "en-us"
@ -119,6 +121,6 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/1.9/howto/static-files/
# https://docs.djangoproject.com/en/3.2/howto/static-files/
STATIC_URL = "/static/"

View File

@ -1,9 +1,8 @@
from django.urls import path
from django.contrib import admin
from django.urls import path
from graphene_django.views import GraphQLView
urlpatterns = [
path("admin/", admin.site.urls),
path("graphql/", GraphQLView.as_view(graphiql=True)),

View File

@ -1,4 +1,3 @@
graphene>=2.1,<3
graphene-django>=2.1,<3
graphql-core>=2.1,<3
django==3.0.7
django~=3.2
graphene
graphene-django>=3.1

View File

@ -1,4 +1,4 @@
Cookbook Example Django Project
Cookbook Example (Relay) Django Project
===============================
This example project demos integration between Graphene and Django.
@ -60,5 +60,5 @@ Now you should be ready to start the server:
Now head on over to
[http://127.0.0.1:8000/graphql](http://127.0.0.1:8000/graphql)
and run some queries!
(See the [Graphene-Django Tutorial](http://docs.graphene-python.org/projects/django/en/latest/tutorial-plain/#testing-our-graphql-schema)
(See the [Graphene-Django Tutorial](http://docs.graphene-python.org/projects/django/en/latest/tutorial-relay/#testing-our-graphql-schema)
for some example queries)

View File

@ -1 +1,52 @@
[{"model": "ingredients.category", "pk": 1, "fields": {"name": "Dairy"}}, {"model": "ingredients.category", "pk": 2, "fields": {"name": "Meat"}}, {"model": "ingredients.ingredient", "pk": 1, "fields": {"name": "Eggs", "notes": "Good old eggs", "category": 1}}, {"model": "ingredients.ingredient", "pk": 2, "fields": {"name": "Milk", "notes": "Comes from a cow", "category": 1}}, {"model": "ingredients.ingredient", "pk": 3, "fields": {"name": "Beef", "notes": "Much like milk, this comes from a cow", "category": 2}}, {"model": "ingredients.ingredient", "pk": 4, "fields": {"name": "Chicken", "notes": "Definitely doesn't come from a cow", "category": 2}}]
[
{
"fields": {
"name": "Dairy"
},
"model": "ingredients.category",
"pk": 1
},
{
"fields": {
"name": "Meat"
},
"model": "ingredients.category",
"pk": 2
},
{
"fields": {
"category": 1,
"name": "Eggs",
"notes": "Good old eggs"
},
"model": "ingredients.ingredient",
"pk": 1
},
{
"fields": {
"category": 1,
"name": "Milk",
"notes": "Comes from a cow"
},
"model": "ingredients.ingredient",
"pk": 2
},
{
"fields": {
"category": 2,
"name": "Beef",
"notes": "Much like milk, this comes from a cow"
},
"model": "ingredients.ingredient",
"pk": 3
},
{
"fields": {
"category": 2,
"name": "Chicken",
"notes": "Definitely doesn't come from a cow"
},
"model": "ingredients.ingredient",
"pk": 4
}
]

View File

@ -1,33 +1,52 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9 on 2015-12-04 18:15
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
dependencies = []
operations = [
migrations.CreateModel(
name='Category',
name="Category",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100)),
],
),
migrations.CreateModel(
name='Ingredient',
name="Ingredient",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('notes', models.TextField()),
('category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='ingredients', to='ingredients.Category')),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=100)),
("notes", models.TextField()),
(
"category",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="ingredients",
to="ingredients.Category",
),
),
],
),
]

View File

@ -1,20 +1,17 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9 on 2016-11-04 00:50
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('ingredients', '0001_initial'),
("ingredients", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name='ingredient',
name='notes',
model_name="ingredient",
name="notes",
field=models.TextField(blank=True, null=True),
),
]

View File

@ -1,8 +1,9 @@
from cookbook.ingredients.models import Category, Ingredient
from graphene import Node
from graphene_django.filter import DjangoFilterConnectionField
from graphene_django.types import DjangoObjectType
from cookbook.ingredients.models import Category, Ingredient
# Graphene will automatically map the Category model's fields onto the CategoryNode.
# This is configured in the CategoryNode's Meta class (as you can see below)
@ -10,6 +11,7 @@ class CategoryNode(DjangoObjectType):
class Meta:
model = Category
interfaces = (Node,)
fields = "__all__"
filter_fields = ["name", "ingredients"]
@ -18,6 +20,7 @@ class IngredientNode(DjangoObjectType):
model = Ingredient
# Allow for some more advanced filtering here
interfaces = (Node,)
fields = "__all__"
filter_fields = {
"name": ["exact", "icontains", "istartswith"],
"notes": ["exact", "icontains"],
@ -26,7 +29,7 @@ class IngredientNode(DjangoObjectType):
}
class Query(object):
class Query:
category = Node.Field(CategoryNode)
all_categories = DjangoFilterConnectionField(CategoryNode)

View File

@ -1,36 +1,69 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9 on 2015-12-04 18:20
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('ingredients', '0001_initial'),
("ingredients", "0001_initial"),
]
operations = [
migrations.CreateModel(
name='Recipe',
name="Recipe",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=100)),
('instructions', models.TextField()),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=100)),
("instructions", models.TextField()),
],
),
migrations.CreateModel(
name='RecipeIngredient',
name="RecipeIngredient",
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('amount', models.FloatField()),
('unit', models.CharField(choices=[('kg', 'Kilograms'), ('l', 'Litres'), ('', 'Units')], max_length=20)),
('ingredient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='used_by', to='ingredients.Ingredient')),
('recipes', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='amounts', to='recipes.Recipe')),
(
"id",
models.AutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("amount", models.FloatField()),
(
"unit",
models.CharField(
choices=[("kg", "Kilograms"), ("l", "Litres"), ("", "Units")],
max_length=20,
),
),
(
"ingredient",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="used_by",
to="ingredients.Ingredient",
),
),
(
"recipes",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="amounts",
to="recipes.Recipe",
),
),
],
),
]

View File

@ -1,25 +1,30 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9 on 2016-11-04 01:06
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('recipes', '0001_initial'),
("recipes", "0001_initial"),
]
operations = [
migrations.RenameField(
model_name='recipeingredient',
old_name='recipes',
new_name='recipe',
model_name="recipeingredient",
old_name="recipes",
new_name="recipe",
),
migrations.AlterField(
model_name='recipeingredient',
name='unit',
field=models.CharField(choices=[(b'unit', b'Units'), (b'kg', b'Kilograms'), (b'l', b'Litres'), (b'st', b'Shots')], max_length=20),
model_name="recipeingredient",
name="unit",
field=models.CharField(
choices=[
(b"unit", b"Units"),
(b"kg", b"Kilograms"),
(b"l", b"Litres"),
(b"st", b"Shots"),
],
max_length=20,
),
),
]

View File

@ -6,7 +6,9 @@ from cookbook.ingredients.models import Ingredient
class Recipe(models.Model):
title = models.CharField(max_length=100)
instructions = models.TextField()
__unicode__ = lambda self: self.title
def __unicode__(self):
return self.title
class RecipeIngredient(models.Model):

View File

@ -1,13 +1,15 @@
from cookbook.recipes.models import Recipe, RecipeIngredient
from graphene import Node
from graphene_django.filter import DjangoFilterConnectionField
from graphene_django.types import DjangoObjectType
from cookbook.recipes.models import Recipe, RecipeIngredient
class RecipeNode(DjangoObjectType):
class Meta:
model = Recipe
interfaces = (Node,)
fields = "__all__"
filter_fields = ["title", "amounts"]
@ -16,6 +18,7 @@ class RecipeIngredientNode(DjangoObjectType):
model = RecipeIngredient
# Allow for some more advanced filtering here
interfaces = (Node,)
fields = "__all__"
filter_fields = {
"ingredient__name": ["exact", "icontains", "istartswith"],
"recipe": ["exact"],
@ -23,7 +26,7 @@ class RecipeIngredientNode(DjangoObjectType):
}
class Query(object):
class Query:
recipe = Node.Field(RecipeNode)
all_recipes = DjangoFilterConnectionField(RecipeNode)

View File

@ -1,8 +1,8 @@
import graphene
from graphene_django.debug import DjangoDebug
import cookbook.ingredients.schema
import cookbook.recipes.schema
import graphene
from graphene_django.debug import DjangoDebug
class Query(

View File

@ -3,7 +3,6 @@ from django.contrib import admin
from graphene_django.views import GraphQLView
urlpatterns = [
url(r"^admin/", admin.site.urls),
url(r"^graphql$", GraphQLView.as_view(graphiql=True)),

View File

@ -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
}
]

View File

@ -1,5 +1,5 @@
graphene>=2.1,<3
graphene-django>=2.1,<3
graphql-core>=2.1,<3
django==3.0.7
django==3.2.25
django-filter>=2

View File

@ -1,5 +1,5 @@
import sys
import os
import sys
ROOT_PATH = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, ROOT_PATH + "/examples/")
@ -28,3 +28,5 @@ TEMPLATES = [
GRAPHENE = {"SCHEMA": "graphene_django.tests.schema_view.schema"}
ROOT_URLCONF = "graphene_django.tests.urls"
USE_TZ = True

View File

@ -28,7 +28,7 @@ def initialize():
# Yeah, technically it's Corellian. But it flew in the service of the rebels,
# so for the purposes of this demo it's a rebel ship.
falcon = Ship(id="4", name="Millenium Falcon", faction=rebels)
falcon = Ship(id="4", name="Millennium Falcon", faction=rebels)
falcon.save()
homeOne = Ship(id="5", name="Home One", faction=rebels)

View File

@ -1,5 +1,3 @@
from __future__ import absolute_import
from django.db import models

View File

@ -1,17 +1,20 @@
import graphene
from graphene import Schema, relay, resolve_only_args
from graphene import Schema, relay
from graphene_django import DjangoConnectionField, DjangoObjectType
from .data import create_ship, get_empire, get_faction, get_rebels, get_ship, get_ships
from .models import Character as CharacterModel
from .models import Faction as FactionModel
from .models import Ship as ShipModel
from .models import (
Character as CharacterModel,
Faction as FactionModel,
Ship as ShipModel,
)
class Ship(DjangoObjectType):
class Meta:
model = ShipModel
interfaces = (relay.Node,)
fields = "__all__"
@classmethod
def get_node(cls, info, id):
@ -22,12 +25,14 @@ class Ship(DjangoObjectType):
class Character(DjangoObjectType):
class Meta:
model = CharacterModel
fields = "__all__"
class Faction(DjangoObjectType):
class Meta:
model = FactionModel
interfaces = (relay.Node,)
fields = "__all__"
@classmethod
def get_node(cls, info, id):
@ -57,16 +62,13 @@ class Query(graphene.ObjectType):
node = relay.Node.Field()
ships = DjangoConnectionField(Ship, description="All the ships.")
@resolve_only_args
def resolve_ships(self):
def resolve_ships(self, info):
return get_ships()
@resolve_only_args
def resolve_rebels(self):
def resolve_rebels(self, info):
return get_rebels()
@resolve_only_args
def resolve_empire(self):
def resolve_empire(self, info):
return get_empire()

View File

@ -40,7 +40,7 @@ def test_mutations():
{"node": {"id": "U2hpcDox", "name": "X-Wing"}},
{"node": {"id": "U2hpcDoy", "name": "Y-Wing"}},
{"node": {"id": "U2hpcDoz", "name": "A-Wing"}},
{"node": {"id": "U2hpcDo0", "name": "Millenium Falcon"}},
{"node": {"id": "U2hpcDo0", "name": "Millennium Falcon"}},
{"node": {"id": "U2hpcDo1", "name": "Home One"}},
{"node": {"id": "U2hpcDo5", "name": "Peter"}},
]

View File

@ -1,11 +1,13 @@
from .fields import DjangoConnectionField, DjangoListField
from .types import DjangoObjectType
from .utils import bypass_get_queryset
__version__ = "2.14.0"
__version__ = "3.2.3"
__all__ = [
"__version__",
"DjangoObjectType",
"DjangoListField",
"DjangoConnectionField",
"bypass_get_queryset",
]

View File

@ -1,5 +1,16 @@
class MissingType(object):
pass
import sys
from collections.abc import Callable
from pathlib import PurePath
# For backwards compatibility, we import JSONField to have it available for import via
# this compat module (https://github.com/graphql-python/graphene-django/issues/1428).
# Django's JSONField is available in Django 3.2+ (the minimum version we support)
from django.db.models import Choices, JSONField
class MissingType:
def __init__(self, *args, **kwargs):
pass
try:
@ -8,14 +19,47 @@ try:
from django.contrib.postgres.fields import (
ArrayField,
HStoreField,
JSONField as PGJSONField,
IntegerRangeField,
RangeField,
)
except ImportError:
ArrayField, HStoreField, PGJSONField, RangeField = (MissingType,) * 4
IntegerRangeField, HStoreField, RangeField = (MissingType,) * 3
# For unit tests we fake ArrayField using JSONFields
if any(
PurePath(sys.argv[0]).match(p)
for p in [
"**/pytest",
"**/py.test",
"**/pytest/__main__.py",
]
):
class ArrayField(JSONField):
def __init__(self, *args, **kwargs):
if len(args) > 0:
self.base_field = args[0]
super().__init__(**kwargs)
else:
ArrayField = MissingType
try:
# JSONField is only available from Django 3.1
from django.db.models import JSONField
from django.utils.choices import normalize_choices
except ImportError:
JSONField = MissingType
def normalize_choices(choices):
if isinstance(choices, type) and issubclass(choices, Choices):
choices = choices.choices
if isinstance(choices, Callable):
choices = choices()
# In restframework==3.15.0, choices are not passed
# as OrderedDict anymore, so it's safer to check
# for a dict
if isinstance(choices, dict):
choices = choices.items()
return choices

View File

@ -1,11 +1,19 @@
from collections import OrderedDict
import inspect
from functools import partial, singledispatch, wraps
from django.db import models
from django.utils.encoding import force_str
from django.utils.functional import Promise
from django.utils.module_loading import import_string
from graphql import GraphQLError
from graphene import (
ID,
UUID,
Boolean,
Date,
DateTime,
Decimal,
Dynamic,
Enum,
Field,
@ -14,48 +22,85 @@ from graphene import (
List,
NonNull,
String,
UUID,
DateTime,
Date,
Time,
Decimal,
)
from graphene.types.json import JSONString
from graphene.types.resolver import get_default_resolver
from graphene.types.scalars import BigInt
from graphene.utils.str_converters import to_camel_case
from graphql import assert_valid_name
try:
from graphql import assert_name
except ImportError:
# Support for older versions of graphql
from graphql import assert_valid_name as assert_name
from graphql.pyutils import register_description
from .compat import ArrayField, HStoreField, RangeField, normalize_choices
from .fields import DjangoConnectionField, DjangoListField
from .settings import graphene_settings
from .compat import ArrayField, HStoreField, JSONField, PGJSONField, RangeField
from .fields import DjangoListField, DjangoConnectionField
from .utils import import_single_dispatch
from .utils.str_converters import to_const
singledispatch = import_single_dispatch()
class BlankValueField(Field):
def wrap_resolve(self, parent_resolver):
resolver = self.resolver or parent_resolver
# create custom resolver
def blank_field_wrapper(func):
@wraps(func)
def wrapped_resolver(*args, **kwargs):
return_value = func(*args, **kwargs)
if return_value == "":
return None
return return_value
return wrapped_resolver
return blank_field_wrapper(resolver)
class EnumValueField(BlankValueField):
def wrap_resolve(self, parent_resolver):
resolver = super().wrap_resolve(parent_resolver)
# create custom resolver
def enum_field_wrapper(func):
@wraps(func)
def wrapped_resolver(*args, **kwargs):
return_value = func(*args, **kwargs)
if isinstance(return_value, models.Choices):
return_value = return_value.value
return return_value
return wrapped_resolver
return enum_field_wrapper(resolver)
def convert_choice_name(name):
name = to_const(force_str(name))
try:
assert_valid_name(name)
except AssertionError:
assert_name(name)
except GraphQLError:
name = "A_%s" % name
return name
def get_choices(choices):
converted_names = []
if isinstance(choices, OrderedDict):
choices = choices.items()
choices = normalize_choices(choices)
for value, help_text in choices:
if isinstance(help_text, (tuple, list)):
for choice in get_choices(help_text):
yield choice
yield from get_choices(help_text)
else:
name = convert_choice_name(value)
while name in converted_names:
name += "_" + str(len(converted_names))
converted_names.append(name)
description = help_text
description = str(
help_text
) # TODO: translatable description: https://github.com/graphql-python/graphql-core-next/issues/58
yield name, value, description
@ -64,12 +109,18 @@ def convert_choices_to_named_enum_with_descriptions(name, choices):
named_choices = [(c[0], c[1]) for c in choices]
named_choices_descriptions = {c[0]: c[2] for c in choices}
class EnumWithDescriptionsType(object):
class EnumWithDescriptionsType:
@property
def description(self):
return named_choices_descriptions[self.name]
return str(named_choices_descriptions[self.name])
return Enum(name, list(named_choices), type=EnumWithDescriptionsType)
return_type = Enum(
name,
list(named_choices),
type=EnumWithDescriptionsType,
description="An enumeration.", # Temporary fix until https://github.com/graphql-python/graphene/pull/1502 is merged
)
return return_type
def generate_enum_name(django_model_meta, field):
@ -79,14 +130,14 @@ def generate_enum_name(django_model_meta, field):
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME
)
name = custom_func(field)
elif graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING is True:
elif graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V2_NAMING is True:
name = to_camel_case(f"{django_model_meta.object_name}_{field.name}")
else:
name = "{app_label}{object_name}{field_name}Choices".format(
app_label=to_camel_case(django_model_meta.app_label.title()),
object_name=django_model_meta.object_name,
field_name=to_camel_case(field.name.title()),
)
else:
name = to_camel_case("{}_{}".format(django_model_meta.object_name, field.name))
return name
@ -98,17 +149,24 @@ def convert_choice_field_to_enum(field, name=None):
def convert_django_field_with_choices(
field, registry=None, convert_choices_to_enum=True
field, registry=None, convert_choices_to_enum=None
):
if registry is not None:
converted = registry.get_converted_field(field)
if converted:
return converted
choices = getattr(field, "choices", None)
if convert_choices_to_enum is None:
convert_choices_to_enum = bool(
graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CONVERT
)
if choices and convert_choices_to_enum:
enum = convert_choice_field_to_enum(field)
EnumCls = convert_choice_field_to_enum(field)
required = not (field.blank or field.null)
converted = enum(description=field.help_text, required=required)
converted = EnumCls(
description=get_django_field_description(field), required=required
).mount_as(EnumValueField)
else:
converted = convert_django_field(field, registry)
if registry is not None:
@ -116,10 +174,14 @@ def convert_django_field_with_choices(
return converted
def get_django_field_description(field):
return str(field.help_text) if field.help_text else None
@singledispatch
def convert_django_field(field, registry=None):
raise Exception(
"Don't know how to convert the Django field %s (%s)" % (field, field.__class__)
f"Don't know how to convert the Django field {field} ({field.__class__})"
)
@ -132,62 +194,88 @@ def convert_django_field(field, registry=None):
@convert_django_field.register(models.FileField)
@convert_django_field.register(models.FilePathField)
def convert_field_to_string(field, registry=None):
return String(description=field.help_text, required=not field.null)
return String(
description=get_django_field_description(field), required=not field.null
)
@convert_django_field.register(models.AutoField)
@convert_django_field.register(models.BigAutoField)
@convert_django_field.register(models.SmallAutoField)
def convert_field_to_id(field, registry=None):
return ID(description=field.help_text, required=not field.null)
return ID(description=get_django_field_description(field), required=not field.null)
@convert_django_field.register(models.UUIDField)
def convert_field_to_uuid(field, registry=None):
return UUID(description=field.help_text, required=not field.null)
return UUID(
description=get_django_field_description(field), required=not field.null
)
@convert_django_field.register(models.BigIntegerField)
def convert_big_int_field(field, registry=None):
return BigInt(description=field.help_text, required=not field.null)
@convert_django_field.register(models.PositiveIntegerField)
@convert_django_field.register(models.PositiveSmallIntegerField)
@convert_django_field.register(models.SmallIntegerField)
@convert_django_field.register(models.BigIntegerField)
@convert_django_field.register(models.IntegerField)
def convert_field_to_int(field, registry=None):
return Int(description=field.help_text, required=not field.null)
return Int(description=get_django_field_description(field), required=not field.null)
@convert_django_field.register(models.NullBooleanField)
@convert_django_field.register(models.BooleanField)
def convert_field_to_boolean(field, registry=None):
return Boolean(description=field.help_text, required=not field.null)
return Boolean(
description=get_django_field_description(field), required=not field.null
)
@convert_django_field.register(models.DecimalField)
def convert_field_to_decimal(field, registry=None):
return Decimal(description=field.help_text, required=not field.null)
return Decimal(
description=get_django_field_description(field), required=not field.null
)
@convert_django_field.register(models.FloatField)
@convert_django_field.register(models.DurationField)
def convert_field_to_float(field, registry=None):
return Float(description=field.help_text, required=not field.null)
return Float(
description=get_django_field_description(field), required=not field.null
)
@convert_django_field.register(models.DateTimeField)
def convert_datetime_to_string(field, registry=None):
return DateTime(description=field.help_text, required=not field.null)
return DateTime(
description=get_django_field_description(field), required=not field.null
)
@convert_django_field.register(models.DateField)
def convert_date_to_string(field, registry=None):
return Date(description=field.help_text, required=not field.null)
return Date(
description=get_django_field_description(field), required=not field.null
)
@convert_django_field.register(models.TimeField)
def convert_time_to_string(field, registry=None):
return Time(description=field.help_text, required=not field.null)
return Time(
description=get_django_field_description(field), required=not field.null
)
@convert_django_field.register(models.OneToOneRel)
def convert_onetoone_field_to_djangomodel(field, registry=None):
from graphene.utils.str_converters import to_snake_case
from .types import DjangoObjectType
model = field.related_model
def dynamic_type():
@ -195,10 +283,55 @@ def convert_onetoone_field_to_djangomodel(field, registry=None):
if not _type:
return
# We do this for a bug in Django 1.8, where null attr
# is not available in the OneToOneRel instance
null = getattr(field, "null", True)
return Field(_type, required=not null)
class CustomField(Field):
def wrap_resolve(self, parent_resolver):
"""
Implements a custom resolver which goes through the `get_node` method to ensure that
it goes through the `get_queryset` method of the DjangoObjectType.
"""
resolver = super().wrap_resolve(parent_resolver)
# If `get_queryset` was not overridden in the DjangoObjectType
# or if we explicitly bypass the `get_queryset` method,
# we can just return the default resolver.
if (
_type.get_queryset.__func__
is DjangoObjectType.get_queryset.__func__
or getattr(resolver, "_bypass_get_queryset", False)
):
return resolver
def custom_resolver(root, info, **args):
# Note: this function is used to resolve 1:1 relation fields
is_resolver_awaitable = inspect.iscoroutinefunction(resolver)
if is_resolver_awaitable:
fk_obj = resolver(root, info, **args)
# In case the resolver is a custom awaitable resolver that overwrites
# the default Django resolver
return fk_obj
field_name = to_snake_case(info.field_name)
reversed_field_name = root.__class__._meta.get_field(
field_name
).remote_field.name
try:
return _type.get_queryset(
_type._meta.model.objects.filter(
**{reversed_field_name: root.pk}
),
info,
).get()
except _type._meta.model.DoesNotExist:
return None
return custom_resolver
return CustomField(
_type,
required=not field.null,
)
return Dynamic(dynamic_type)
@ -214,11 +347,10 @@ def convert_field_to_list_or_connection(field, registry=None):
if not _type:
return
description = (
field.help_text
if isinstance(field, models.ManyToManyField)
else field.field.help_text
)
if isinstance(field, models.ManyToManyField):
description = get_django_field_description(field)
else:
description = get_django_field_description(field.field)
# If there is a connection, we should transform the field
# into a DjangoConnectionField
@ -247,6 +379,10 @@ def convert_field_to_list_or_connection(field, registry=None):
@convert_django_field.register(models.OneToOneField)
@convert_django_field.register(models.ForeignKey)
def convert_field_to_djangomodel(field, registry=None):
from graphene.utils.str_converters import to_snake_case
from .types import DjangoObjectType
model = field.related_model
def dynamic_type():
@ -254,7 +390,83 @@ def convert_field_to_djangomodel(field, registry=None):
if not _type:
return
return Field(_type, description=field.help_text, required=not field.null)
class CustomField(Field):
def wrap_resolve(self, parent_resolver):
"""
Implements a custom resolver which goes through the `get_node` method to ensure that
it goes through the `get_queryset` method of the DjangoObjectType.
"""
resolver = super().wrap_resolve(parent_resolver)
# If `get_queryset` was not overridden in the DjangoObjectType
# or if we explicitly bypass the `get_queryset` method,
# we can just return the default resolver.
if (
_type.get_queryset.__func__
is DjangoObjectType.get_queryset.__func__
or getattr(resolver, "_bypass_get_queryset", False)
):
return resolver
def custom_resolver(root, info, **args):
# Note: this function is used to resolve FK or 1:1 fields
# it does not differentiate between custom-resolved fields
# and default resolved fields.
# because this is a django foreign key or one-to-one field, the primary-key for
# this node can be accessed from the root node.
# ex: article.reporter_id
# get the name of the id field from the root's model
field_name = to_snake_case(info.field_name)
db_field_key = root.__class__._meta.get_field(field_name).attname
if hasattr(root, db_field_key):
# get the object's primary-key from root
object_pk = getattr(root, db_field_key)
else:
return None
is_resolver_awaitable = inspect.iscoroutinefunction(resolver)
if is_resolver_awaitable:
fk_obj = resolver(root, info, **args)
# In case the resolver is a custom awaitable resolver that overwrites
# the default Django resolver
return fk_obj
instance_from_get_node = _type.get_node(info, object_pk)
if instance_from_get_node is None:
# no instance to return
return
elif (
isinstance(resolver, partial)
and resolver.func is get_default_resolver()
):
return instance_from_get_node
elif resolver is not get_default_resolver():
# Default resolver is overridden
# For optimization, add the instance to the resolver
setattr(root, field_name, instance_from_get_node)
# Explanation:
# previously, _type.get_node` is called which results in at least one hit to the database.
# But, if we did not pass the instance to the root, calling the resolver will result in
# another call to get the instance which results in at least two database queries in total
# to resolve this node only.
# That's why the value of the object is set in the root so when the object is accessed
# in the resolver (root.field_name) it does not access the database unless queried explicitly.
fk_obj = resolver(root, info, **args)
return fk_obj
else:
return instance_from_get_node
return custom_resolver
return CustomField(
_type,
description=get_django_field_description(field),
required=not field.null,
)
return Dynamic(dynamic_type)
@ -268,14 +480,19 @@ def convert_postgres_array_to_list(field, registry=None):
if inner_type.kwargs["required"]
else type(inner_type)
)
return List(inner_type, description=field.help_text, required=not field.null)
return List(
inner_type,
description=get_django_field_description(field),
required=not field.null,
)
@convert_django_field.register(HStoreField)
@convert_django_field.register(PGJSONField)
@convert_django_field.register(JSONField)
def convert_pg_and_json_field_to_string(field, registry=None):
return JSONString(description=field.help_text, required=not field.null)
@convert_django_field.register(models.JSONField)
def convert_json_field_to_string(field, registry=None):
return JSONString(
description=get_django_field_description(field), required=not field.null
)
@convert_django_field.register(RangeField)
@ -287,4 +504,13 @@ def convert_postgres_range_to_string(field, registry=None):
if inner_type.kwargs["required"]
else type(inner_type)
)
return List(inner_type, description=field.help_text, required=not field.null)
return List(
inner_type,
description=get_django_field_description(field),
required=not field.null,
)
# Register Django lazy()-wrapped values as GraphQL description/help_text.
# This is needed for using lazy translations, see https://github.com/graphql-python/graphql-core-next/issues/58.
register_description(Promise)

View File

@ -0,0 +1,17 @@
import traceback
from django.utils.encoding import force_str
from .types import DjangoDebugException
def wrap_exception(exception):
return DjangoDebugException(
message=force_str(exception),
exc_type=force_str(type(exception)),
stack="".join(
traceback.format_exception(
exception, value=exception, tb=exception.__traceback__
)
),
)

View File

@ -0,0 +1,10 @@
from graphene import ObjectType, String
class DjangoDebugException(ObjectType):
class Meta:
description = "Represents a single exception raised."
exc_type = String(required=True, description="The class of the exception")
message = String(required=True, description="The message of the exception")
stack = String(required=True, description="The stack trace")

View File

@ -1,34 +1,38 @@
from django.db import connections
from promise import Promise
from .exception.formating import wrap_exception
from .sql.tracking import unwrap_cursor, wrap_cursor
from .types import DjangoDebug
class DjangoDebugContext(object):
class DjangoDebugContext:
def __init__(self):
self.debug_promise = None
self.promises = []
self.debug_result = None
self.results = []
self.object = DjangoDebug(sql=[], exceptions=[])
self.enable_instrumentation()
self.object = DjangoDebug(sql=[])
def get_debug_promise(self):
if not self.debug_promise:
self.debug_promise = Promise.all(self.promises)
self.promises = []
return self.debug_promise.then(self.on_resolve_all_promises)
def get_debug_result(self):
if not self.debug_result:
self.debug_result = self.results
self.results = []
return self.on_resolve_all_results()
def on_resolve_all_promises(self, values):
if self.promises:
self.debug_promise = None
return self.get_debug_promise()
def on_resolve_error(self, value):
if hasattr(self, "object"):
self.object.exceptions.append(wrap_exception(value))
return value
def on_resolve_all_results(self):
if self.results:
self.debug_result = None
return self.get_debug_result()
self.disable_instrumentation()
return self.object
def add_promise(self, promise):
if self.debug_promise:
self.promises.append(promise)
def add_result(self, result):
if self.debug_result:
self.results.append(result)
def enable_instrumentation(self):
# This is thread-safe because database connections are thread-local.
@ -40,7 +44,7 @@ class DjangoDebugContext(object):
unwrap_cursor(connection)
class DjangoDebugMiddleware(object):
class DjangoDebugMiddleware:
def resolve(self, next, root, info, **args):
context = info.context
django_debug = getattr(context, "django_debug", None)
@ -56,7 +60,10 @@ class DjangoDebugMiddleware(object):
)
)
if info.schema.get_type("DjangoDebug") == info.return_type:
return context.django_debug.get_debug_promise()
promise = next(root, info, **args)
context.django_debug.add_promise(promise)
return promise
return context.django_debug.get_debug_result()
try:
result = next(root, info, **args)
except Exception as e:
return context.django_debug.on_resolve_error(e)
context.django_debug.add_result(result)
return result

View File

@ -1,11 +1,9 @@
# Code obtained from django-debug-toolbar sql panel tracking
from __future__ import absolute_import, unicode_literals
import json
from threading import local
from time import time
import six
from django.utils.encoding import force_str
from .types import DjangoDebugSQL
@ -51,7 +49,7 @@ def unwrap_cursor(connection):
del connection._graphene_cursor
class ExceptionCursorWrapper(object):
class ExceptionCursorWrapper:
"""
Wraps a cursor and raises an exception on any operation.
Used in Templates panel.
@ -64,7 +62,7 @@ class ExceptionCursorWrapper(object):
raise SQLQueryTriggered()
class NormalCursorWrapper(object):
class NormalCursorWrapper:
"""
Wraps a cursor and logs queries.
"""
@ -77,7 +75,7 @@ class NormalCursorWrapper(object):
self.logger = logger
def _quote_expr(self, element):
if isinstance(element, six.string_types):
if isinstance(element, str):
return "'%s'" % force_str(element).replace("'", "''")
else:
return repr(element)
@ -86,7 +84,7 @@ class NormalCursorWrapper(object):
if not params:
return params
if isinstance(params, dict):
return dict((key, self._quote_expr(value)) for key, value in params.items())
return {key: self._quote_expr(value) for key, value in params.items()}
return list(map(self._quote_expr, params))
def _decode(self, param):

View File

@ -1,5 +1,6 @@
import graphene
import pytest
import graphene
from graphene.relay import Node
from graphene_django import DjangoConnectionField, DjangoObjectType
@ -8,7 +9,7 @@ from ..middleware import DjangoDebugMiddleware
from ..types import DjangoDebug
class context(object):
class context:
pass
@ -22,10 +23,11 @@ def test_should_query_field():
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
class Query(graphene.ObjectType):
reporter = graphene.Field(ReporterType)
debug = graphene.Field(DjangoDebug, name="__debug")
debug = graphene.Field(DjangoDebug, name="_debug")
def resolve_reporter(self, info, **args):
return Reporter.objects.first()
@ -35,7 +37,7 @@ def test_should_query_field():
reporter {
lastName
}
__debug {
_debug {
sql {
rawSql
}
@ -44,9 +46,7 @@ def test_should_query_field():
"""
expected = {
"reporter": {"lastName": "ABA"},
"__debug": {
"sql": [{"rawSql": str(Reporter.objects.order_by("pk")[:1].query)}]
},
"_debug": {"sql": [{"rawSql": str(Reporter.objects.order_by("pk")[:1].query)}]},
}
schema = graphene.Schema(query=Query)
result = schema.execute(
@ -71,10 +71,11 @@ def test_should_query_nested_field(graphene_settings, max_limit):
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
class Query(graphene.ObjectType):
reporter = graphene.Field(ReporterType)
debug = graphene.Field(DjangoDebug, name="__debug")
debug = graphene.Field(DjangoDebug, name="_debug")
def resolve_reporter(self, info, **args):
return Reporter.objects.first()
@ -88,7 +89,7 @@ def test_should_query_nested_field(graphene_settings, max_limit):
pets { edges { node { lastName } } }
} } }
}
__debug {
_debug {
sql {
rawSql
}
@ -116,12 +117,12 @@ def test_should_query_nested_field(graphene_settings, max_limit):
)
assert not result.errors
query = str(Reporter.objects.order_by("pk")[:1].query)
assert result.data["__debug"]["sql"][0]["rawSql"] == query
assert "COUNT" in result.data["__debug"]["sql"][1]["rawSql"]
assert "tests_reporter_pets" in result.data["__debug"]["sql"][2]["rawSql"]
assert "COUNT" in result.data["__debug"]["sql"][3]["rawSql"]
assert "tests_reporter_pets" in result.data["__debug"]["sql"][4]["rawSql"]
assert len(result.data["__debug"]["sql"]) == 5
assert result.data["_debug"]["sql"][0]["rawSql"] == query
assert "COUNT" in result.data["_debug"]["sql"][1]["rawSql"]
assert "tests_reporter_pets" in result.data["_debug"]["sql"][2]["rawSql"]
assert "COUNT" in result.data["_debug"]["sql"][3]["rawSql"]
assert "tests_reporter_pets" in result.data["_debug"]["sql"][4]["rawSql"]
assert len(result.data["_debug"]["sql"]) == 5
assert result.data["reporter"] == expected["reporter"]
@ -136,10 +137,11 @@ def test_should_query_list():
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
class Query(graphene.ObjectType):
all_reporters = graphene.List(ReporterType)
debug = graphene.Field(DjangoDebug, name="__debug")
debug = graphene.Field(DjangoDebug, name="_debug")
def resolve_all_reporters(self, info, **args):
return Reporter.objects.all()
@ -149,7 +151,7 @@ def test_should_query_list():
allReporters {
lastName
}
__debug {
_debug {
sql {
rawSql
}
@ -158,7 +160,7 @@ def test_should_query_list():
"""
expected = {
"allReporters": [{"lastName": "ABA"}, {"lastName": "Griffin"}],
"__debug": {"sql": [{"rawSql": str(Reporter.objects.all().query)}]},
"_debug": {"sql": [{"rawSql": str(Reporter.objects.all().query)}]},
}
schema = graphene.Schema(query=Query)
result = schema.execute(
@ -181,10 +183,11 @@ def test_should_query_connection(graphene_settings, max_limit):
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
debug = graphene.Field(DjangoDebug, name="__debug")
debug = graphene.Field(DjangoDebug, name="_debug")
def resolve_all_reporters(self, info, **args):
return Reporter.objects.all()
@ -198,7 +201,7 @@ def test_should_query_connection(graphene_settings, max_limit):
}
}
}
__debug {
_debug {
sql {
rawSql
}
@ -212,10 +215,10 @@ def test_should_query_connection(graphene_settings, max_limit):
)
assert not result.errors
assert result.data["allReporters"] == expected["allReporters"]
assert len(result.data["__debug"]["sql"]) == 2
assert "COUNT" in result.data["__debug"]["sql"][0]["rawSql"]
assert len(result.data["_debug"]["sql"]) == 2
assert "COUNT" in result.data["_debug"]["sql"][0]["rawSql"]
query = str(Reporter.objects.all()[:1].query)
assert result.data["__debug"]["sql"][1]["rawSql"] == query
assert result.data["_debug"]["sql"][1]["rawSql"] == query
@pytest.mark.parametrize("max_limit", [None, 100])
@ -233,11 +236,12 @@ def test_should_query_connectionfilter(graphene_settings, max_limit):
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
class Query(graphene.ObjectType):
all_reporters = DjangoFilterConnectionField(ReporterType, fields=["last_name"])
s = graphene.String(resolver=lambda *_: "S")
debug = graphene.Field(DjangoDebug, name="__debug")
debug = graphene.Field(DjangoDebug, name="_debug")
def resolve_all_reporters(self, info, **args):
return Reporter.objects.all()
@ -251,7 +255,7 @@ def test_should_query_connectionfilter(graphene_settings, max_limit):
}
}
}
__debug {
_debug {
sql {
rawSql
}
@ -265,7 +269,46 @@ def test_should_query_connectionfilter(graphene_settings, max_limit):
)
assert not result.errors
assert result.data["allReporters"] == expected["allReporters"]
assert len(result.data["__debug"]["sql"]) == 2
assert "COUNT" in result.data["__debug"]["sql"][0]["rawSql"]
assert len(result.data["_debug"]["sql"]) == 2
assert "COUNT" in result.data["_debug"]["sql"][0]["rawSql"]
query = str(Reporter.objects.all()[:1].query)
assert result.data["__debug"]["sql"][1]["rawSql"] == query
assert result.data["_debug"]["sql"][1]["rawSql"] == query
def test_should_query_stack_trace():
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
class Query(graphene.ObjectType):
reporter = graphene.Field(ReporterType)
debug = graphene.Field(DjangoDebug, name="_debug")
def resolve_reporter(self, info, **args):
raise Exception("caught stack trace")
query = """
query ReporterQuery {
reporter {
lastName
}
_debug {
exceptions {
message
stack
}
}
}
"""
schema = graphene.Schema(query=Query)
result = schema.execute(
query, context_value=context(), middleware=[DjangoDebugMiddleware()]
)
assert result.errors
assert len(result.data["_debug"]["exceptions"])
debug_exception = result.data["_debug"]["exceptions"][0]
assert debug_exception["stack"].count("\n") > 1
assert "test_query.py" in debug_exception["stack"]
assert debug_exception["message"] == "caught stack trace"

View File

@ -1,5 +1,6 @@
from graphene import List, ObjectType
from .exception.types import DjangoDebugException
from .sql.types import DjangoDebugSQL
@ -8,3 +9,6 @@ class DjangoDebug(ObjectType):
description = "Debugging information for the current query."
sql = List(DjangoDebugSQL, description="Executed SQL queries for this API query.")
exceptions = List(
DjangoDebugException, description="Raise exceptions for this API query."
)

View File

@ -1,9 +1,8 @@
from functools import partial
import six
from django.db.models.query import QuerySet
from graphql_relay.connection.arrayconnection import (
connection_from_list_slice,
from graphql_relay import (
connection_from_array_slice,
cursor_to_offset,
get_offset_with_default,
offset_to_cursor,
@ -11,7 +10,8 @@ from graphql_relay.connection.arrayconnection import (
from promise import Promise
from graphene import Int, NonNull
from graphene.relay import ConnectionField, PageInfo
from graphene.relay import ConnectionField
from graphene.relay.connection import connection_adapter, page_info_adapter
from graphene.types import Field, List
from .settings import graphene_settings
@ -20,17 +20,20 @@ from .utils import maybe_queryset
class DjangoListField(Field):
def __init__(self, _type, *args, **kwargs):
from .types import DjangoObjectType
if isinstance(_type, NonNull):
_type = _type.of_type
# Django would never return a Set of None vvvvvvv
super(DjangoListField, self).__init__(List(NonNull(_type)), *args, **kwargs)
super().__init__(List(NonNull(_type)), *args, **kwargs)
@property
def type(self):
from .types import DjangoObjectType
assert issubclass(
self._underlying_type, DjangoObjectType
), "DjangoListField only accepts DjangoObjectType types"
), "DjangoListField only accepts DjangoObjectType types as underlying type"
return super().type
@property
def _underlying_type(self):
@ -60,13 +63,17 @@ class DjangoListField(Field):
return queryset
def get_resolver(self, parent_resolver):
def wrap_resolve(self, parent_resolver):
resolver = super().wrap_resolve(parent_resolver)
_type = self.type
if isinstance(_type, NonNull):
_type = _type.of_type
django_object_type = _type.of_type.of_type
return partial(
self.list_resolver, django_object_type, parent_resolver, self.get_manager(),
self.list_resolver,
django_object_type,
resolver,
self.get_manager(),
)
@ -81,7 +88,7 @@ class DjangoConnectionField(ConnectionField):
graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST,
)
kwargs.setdefault("offset", Int())
super(DjangoConnectionField, self).__init__(*args, **kwargs)
super().__init__(*args, **kwargs)
@property
def type(self):
@ -143,37 +150,40 @@ class DjangoConnectionField(ConnectionField):
iterable = maybe_queryset(iterable)
if isinstance(iterable, QuerySet):
list_length = iterable.count()
array_length = iterable.count()
else:
list_length = len(iterable)
list_slice_length = (
min(max_limit, list_length) if max_limit is not None else list_length
)
array_length = len(iterable)
# If after is higher than list_length, connection_from_list_slice
# If after is higher than array_length, connection_from_array_slice
# would try to do a negative slicing which makes django throw an
# AssertionError
after = min(get_offset_with_default(args.get("after"), -1) + 1, list_length)
slice_start = min(
get_offset_with_default(args.get("after"), -1) + 1,
array_length,
)
array_slice_length = array_length - slice_start
if max_limit is not None and "first" not in args:
if "last" in args:
args["first"] = list_length
list_slice_length = list_length
else:
args["first"] = max_limit
# Impose the maximum limit via the `first` field if neither first or last are already provided
# (note that if any of them is provided they must be under max_limit otherwise an error is raised).
if (
max_limit is not None
and args.get("first", None) is None
and args.get("last", None) is None
):
args["first"] = max_limit
connection = connection_from_list_slice(
iterable[after:],
connection = connection_from_array_slice(
iterable[slice_start:],
args,
slice_start=after,
list_length=list_length,
list_slice_length=list_slice_length,
connection_type=connection,
slice_start=slice_start,
array_length=array_length,
array_slice_length=array_slice_length,
connection_type=partial(connection_adapter, connection),
edge_type=connection.Edge,
pageinfo_type=PageInfo,
page_info_type=page_info_adapter,
)
connection.iterable = iterable
connection.length = list_length
connection.length = array_length
return connection
@classmethod
@ -187,7 +197,7 @@ class DjangoConnectionField(ConnectionField):
enforce_first_or_last,
root,
info,
**args
**args,
):
first = args.get("first")
last = args.get("last")
@ -234,10 +244,10 @@ class DjangoConnectionField(ConnectionField):
return on_resolve(iterable)
def get_resolver(self, parent_resolver):
def wrap_resolve(self, parent_resolver):
return partial(
self.connection_resolver,
parent_resolver,
self.resolver or parent_resolver,
self.connection_type,
self.get_manager(),
self.get_queryset_resolver(),

View File

@ -1,4 +1,5 @@
import warnings
from ..utils import DJANGO_FILTER_INSTALLED
if not DJANGO_FILTER_INSTALLED:
@ -9,10 +10,21 @@ if not DJANGO_FILTER_INSTALLED:
)
else:
from .fields import DjangoFilterConnectionField
from .filterset import GlobalIDFilter, GlobalIDMultipleChoiceFilter
from .filters import (
ArrayFilter,
GlobalIDFilter,
GlobalIDMultipleChoiceFilter,
ListFilter,
RangeFilter,
TypedFilter,
)
__all__ = [
"DjangoFilterConnectionField",
"GlobalIDFilter",
"GlobalIDMultipleChoiceFilter",
"ArrayFilter",
"ListFilter",
"RangeFilter",
"TypedFilter",
]

View File

@ -2,22 +2,41 @@ from collections import OrderedDict
from functools import partial
from django.core.exceptions import ValidationError
from graphene.types.argument import to_arguments
from graphene.types.enum import EnumType
from graphene.utils.str_converters import to_snake_case
from ..fields import DjangoConnectionField
from .utils import get_filtering_args_from_filterset, get_filterset_class
def convert_enum(data):
"""
Check if the data is a enum option (or potentially nested list of enum option)
and convert it to its value.
This method is used to pre-process the data for the filters as they can take an
graphene.Enum as argument, but filters (from django_filters) expect a simple value.
"""
if isinstance(data, list):
return [convert_enum(item) for item in data]
if isinstance(type(data), EnumType):
return data.value
else:
return data
class DjangoFilterConnectionField(DjangoConnectionField):
def __init__(
self,
type,
type_,
fields=None,
order_by=None,
extra_filter_meta=None,
filterset_class=None,
*args,
**kwargs
**kwargs,
):
self._fields = fields
self._provided_filterset_class = filterset_class
@ -25,7 +44,7 @@ class DjangoFilterConnectionField(DjangoConnectionField):
self._filtering_args = None
self._extra_filter_meta = extra_filter_meta
self._base_args = None
super(DjangoFilterConnectionField, self).__init__(type, *args, **kwargs)
super().__init__(type_, *args, **kwargs)
@property
def args(self):
@ -39,12 +58,12 @@ class DjangoFilterConnectionField(DjangoConnectionField):
def filterset_class(self):
if not self._filterset_class:
fields = self._fields or self.node_type._meta.filter_fields
meta = dict(model=self.model, fields=fields)
meta = {"model": self.model, "fields": fields}
if self._extra_filter_meta:
meta.update(self._extra_filter_meta)
filterset_class = self._provided_filterset_class or (
self.node_type._meta.filterset_class
filterset_class = (
self._provided_filterset_class or self.node_type._meta.filterset_class
)
self._filterset_class = get_filterset_class(filterset_class, **meta)
@ -66,19 +85,17 @@ class DjangoFilterConnectionField(DjangoConnectionField):
kwargs = {}
for k, v in args.items():
if k in filtering_args:
if k == "order_by":
if k == "order_by" and v is not None:
v = to_snake_case(v)
kwargs[k] = v
kwargs[k] = convert_enum(v)
return kwargs
qs = super(DjangoFilterConnectionField, cls).resolve_queryset(
connection, iterable, info, args
)
qs = super().resolve_queryset(connection, iterable, info, args)
filterset = filterset_class(
data=filter_kwargs(), queryset=qs, request=info.context
)
if filterset.form.is_valid():
if filterset.is_valid():
return filterset.qs
raise ValidationError(filterset.form.errors.as_json())

View File

@ -0,0 +1,26 @@
import warnings
from ...utils import DJANGO_FILTER_INSTALLED
if not DJANGO_FILTER_INSTALLED:
warnings.warn(
"Use of django filtering requires the django-filter package "
"be installed. You can do so using `pip install django-filter`",
ImportWarning,
)
else:
from .array_filter import ArrayFilter
from .global_id_filter import GlobalIDFilter, GlobalIDMultipleChoiceFilter
from .list_filter import ListFilter
from .range_filter import RangeFilter
from .typed_filter import TypedFilter
__all__ = [
"DjangoFilterConnectionField",
"GlobalIDFilter",
"GlobalIDMultipleChoiceFilter",
"ArrayFilter",
"ListFilter",
"RangeFilter",
"TypedFilter",
]

View File

@ -0,0 +1,50 @@
from django_filters.constants import EMPTY_VALUES
from django_filters.filters import FilterMethod
from .typed_filter import TypedFilter
class ArrayFilterMethod(FilterMethod):
def __call__(self, qs, value):
if value is None:
return qs
return self.method(qs, self.f.field_name, value)
class ArrayFilter(TypedFilter):
"""
Filter made for PostgreSQL ArrayField.
"""
@TypedFilter.method.setter
def method(self, value):
"""
Override method setter so that in case a custom `method` is provided
(see documentation https://django-filter.readthedocs.io/en/stable/ref/filters.html#method),
it doesn't fall back to checking if the value is in `EMPTY_VALUES` (from the `__call__` method
of the `FilterMethod` class) and instead use our ArrayFilterMethod that consider empty lists as values.
Indeed when providing a `method` the `filter` method below is overridden and replaced by `FilterMethod(self)`
which means that the validation of the empty value is made by the `FilterMethod.__call__` method instead.
"""
TypedFilter.method.fset(self, value)
if value is not None:
self.filter = ArrayFilterMethod(self)
def filter(self, qs, value):
"""
Override the default filter class to check first whether the list is
empty or not.
This needs to be done as in this case we expect to get the filter applied with
an empty list since it's a valid value but django_filter consider an empty list
to be an empty input value (see `EMPTY_VALUES`) meaning that
the filter does not need to be applied (hence returning the original
queryset).
"""
if value in EMPTY_VALUES and value != []:
return qs
if self.distinct:
qs = qs.distinct()
lookup = f"{self.field_name}__{self.lookup_expr}"
qs = self.get_method(qs)(**{lookup: value})
return qs

View File

@ -0,0 +1,27 @@
from django_filters import Filter, MultipleChoiceFilter
from graphql_relay.node.node import from_global_id
from ...forms import GlobalIDFormField, GlobalIDMultipleChoiceField
class GlobalIDFilter(Filter):
"""
Filter for Relay global ID.
"""
field_class = GlobalIDFormField
def filter(self, qs, value):
"""Convert the filter value to a primary key before filtering"""
_id = None
if value is not None:
_, _id = from_global_id(value)
return super().filter(qs, _id)
class GlobalIDMultipleChoiceFilter(MultipleChoiceFilter):
field_class = GlobalIDMultipleChoiceField
def filter(self, qs, value):
gids = [from_global_id(v)[1] for v in value]
return super().filter(qs, gids)

View File

@ -0,0 +1,50 @@
from django_filters.filters import FilterMethod
from .typed_filter import TypedFilter
class ListFilterMethod(FilterMethod):
def __call__(self, qs, value):
if value is None:
return qs
return self.method(qs, self.f.field_name, value)
class ListFilter(TypedFilter):
"""
Filter that takes a list of value as input.
It is for example used for `__in` filters.
"""
@TypedFilter.method.setter
def method(self, value):
"""
Override method setter so that in case a custom `method` is provided
(see documentation https://django-filter.readthedocs.io/en/stable/ref/filters.html#method),
it doesn't fall back to checking if the value is in `EMPTY_VALUES` (from the `__call__` method
of the `FilterMethod` class) and instead use our ListFilterMethod that consider empty lists as values.
Indeed when providing a `method` the `filter` method below is overridden and replaced by `FilterMethod(self)`
which means that the validation of the empty value is made by the `FilterMethod.__call__` method instead.
"""
TypedFilter.method.fset(self, value)
if value is not None:
self.filter = ListFilterMethod(self)
def filter(self, qs, value):
"""
Override the default filter class to check first whether the list is
empty or not.
This needs to be done as in this case we expect to get an empty output
(if not an exclude filter) but django_filter consider an empty list
to be an empty input value (see `EMPTY_VALUES`) meaning that
the filter does not need to be applied (hence returning the original
queryset).
"""
if value is not None and len(value) == 0:
if self.exclude:
return qs
else:
return qs.none()
else:
return super().filter(qs, value)

View File

@ -0,0 +1,24 @@
from django.core.exceptions import ValidationError
from django.forms import Field
from .typed_filter import TypedFilter
def validate_range(value):
"""
Validator for range filter input: the list of value must be of length 2.
Note that validators are only run if the value is not empty.
"""
if len(value) != 2:
raise ValidationError(
"Invalid range specified: it needs to contain 2 values.", code="invalid"
)
class RangeField(Field):
default_validators = [validate_range]
empty_values = [None]
class RangeFilter(TypedFilter):
field_class = RangeField

View File

@ -0,0 +1,27 @@
from django_filters import Filter
from graphene.types.utils import get_type
class TypedFilter(Filter):
"""
Filter class for which the input GraphQL type can explicitly be provided.
If it is not provided, when building the schema, it will try to guess
it from the field.
"""
def __init__(self, input_type=None, *args, **kwargs):
self._input_type = input_type
super().__init__(*args, **kwargs)
@property
def input_type(self):
input_type = get_type(self._input_type)
if input_type is not None:
if not callable(getattr(input_type, "get_type", None)):
raise ValueError(
"Wrong `input_type` for {}: it only accepts graphene types, got {}".format(
self.__class__.__name__, input_type
)
)
return input_type

View File

@ -1,33 +1,13 @@
import itertools
from django.db import models
from django_filters import Filter, MultipleChoiceFilter, VERSION
from django_filters.filterset import BaseFilterSet, FilterSet
from django_filters.filterset import FILTER_FOR_DBFIELD_DEFAULTS
from graphql_relay.node.node import from_global_id
from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField
class GlobalIDFilter(Filter):
field_class = GlobalIDFormField
def filter(self, qs, value):
""" Convert the filter value to a primary key before filtering """
_id = None
if value is not None:
_, _id = from_global_id(value)
return super(GlobalIDFilter, self).filter(qs, _id)
class GlobalIDMultipleChoiceFilter(MultipleChoiceFilter):
field_class = GlobalIDMultipleChoiceField
def filter(self, qs, value):
gids = [from_global_id(v)[1] for v in value]
return super(GlobalIDMultipleChoiceFilter, self).filter(qs, gids)
from django_filters.filterset import (
FILTER_FOR_DBFIELD_DEFAULTS,
BaseFilterSet,
FilterSet,
)
from .filters import GlobalIDFilter, GlobalIDMultipleChoiceFilter
GRAPHENE_FILTER_SET_OVERRIDES = {
models.AutoField: {"filter_class": GlobalIDFilter},
@ -40,8 +20,8 @@ GRAPHENE_FILTER_SET_OVERRIDES = {
class GrapheneFilterSetMixin(BaseFilterSet):
""" A django_filters.filterset.BaseFilterSet with default filter overrides
to handle global IDs """
"""A django_filters.filterset.BaseFilterSet with default filter overrides
to handle global IDs"""
FILTER_DEFAULTS = dict(
itertools.chain(
@ -50,51 +30,19 @@ class GrapheneFilterSetMixin(BaseFilterSet):
)
# To support a Django 1.11 + Python 2.7 combination django-filter must be
# < 2.x.x. To support the earlier version of django-filter, the
# filter_for_reverse_field method must be present on GrapheneFilterSetMixin and
# must not be present for later versions of django-filter.
if VERSION[0] < 2:
from django.utils.text import capfirst
class GrapheneFilterSetMixinPython2(GrapheneFilterSetMixin):
@classmethod
def filter_for_reverse_field(cls, f, name):
"""Handles retrieving filters for reverse relationships
We override the default implementation so that we can handle
Global IDs (the default implementation expects database
primary keys)
"""
try:
rel = f.field.remote_field
except AttributeError:
rel = f.field.rel
default = {"name": name, "label": capfirst(rel.related_name)}
if rel.multiple:
# For to-many relationships
return GlobalIDMultipleChoiceFilter(**default)
else:
# For to-one relationships
return GlobalIDFilter(**default)
GrapheneFilterSetMixin = GrapheneFilterSetMixinPython2
def setup_filterset(filterset_class):
""" Wrap a provided filterset in Graphene-specific functionality
"""
"""Wrap a provided filterset in Graphene-specific functionality"""
return type(
"Graphene{}".format(filterset_class.__name__),
f"Graphene{filterset_class.__name__}",
(filterset_class, GrapheneFilterSetMixin),
{},
)
def custom_filterset_factory(model, filterset_base_class=FilterSet, **meta):
""" Create a filterset for the given model using the provided meta data
"""
"""Create a filterset for the given model using the provided meta data"""
meta.update({"model": model})
meta_class = type(str("Meta"), (object,), meta)
meta_class = type("Meta", (object,), meta)
filterset = type(
str("%sFilterSet" % model._meta.object_name),
(filterset_base_class, GrapheneFilterSetMixin),

View File

@ -0,0 +1,192 @@
from functools import reduce
import pytest
from django.db import models
from django.db.models.query import QuerySet
from django_filters import FilterSet
import graphene
from graphene.relay import Node
from graphene_django import DjangoObjectType
from graphene_django.filter import ArrayFilter
from graphene_django.utils import DJANGO_FILTER_INSTALLED
from ...compat import ArrayField
pytestmark = []
if DJANGO_FILTER_INSTALLED:
from graphene_django.filter import DjangoFilterConnectionField
else:
pytestmark.append(
pytest.mark.skipif(
True, reason="django_filters not installed or not compatible"
)
)
class Event(models.Model):
name = models.CharField(max_length=50)
tags = ArrayField(models.CharField(max_length=50))
tag_ids = ArrayField(models.IntegerField())
random_field = ArrayField(models.BooleanField())
def __repr__(self):
return f"Event [{self.name}]"
@pytest.fixture
def EventFilterSet():
class EventFilterSet(FilterSet):
class Meta:
model = Event
fields = {
"name": ["exact", "contains"],
}
# Those are actually usable with our Query fixture below
tags__contains = ArrayFilter(field_name="tags", lookup_expr="contains")
tags__overlap = ArrayFilter(field_name="tags", lookup_expr="overlap")
tags = ArrayFilter(field_name="tags", lookup_expr="exact")
tags__len = ArrayFilter(
field_name="tags", lookup_expr="len", input_type=graphene.Int
)
tags__len__in = ArrayFilter(
field_name="tags",
method="tags__len__in_filter",
input_type=graphene.List(graphene.Int),
)
# Those are actually not usable and only to check type declarations
tags_ids__contains = ArrayFilter(field_name="tag_ids", lookup_expr="contains")
tags_ids__overlap = ArrayFilter(field_name="tag_ids", lookup_expr="overlap")
tags_ids = ArrayFilter(field_name="tag_ids", lookup_expr="exact")
random_field__contains = ArrayFilter(
field_name="random_field", lookup_expr="contains"
)
random_field__overlap = ArrayFilter(
field_name="random_field", lookup_expr="overlap"
)
random_field = ArrayFilter(field_name="random_field", lookup_expr="exact")
def tags__len__in_filter(self, queryset, _name, value):
if not value:
return queryset.none()
return reduce(
lambda q1, q2: q1.union(q2),
[queryset.filter(tags__len=v) for v in value],
).distinct()
return EventFilterSet
@pytest.fixture
def EventType(EventFilterSet):
class EventType(DjangoObjectType):
class Meta:
model = Event
interfaces = (Node,)
fields = "__all__"
filterset_class = EventFilterSet
return EventType
@pytest.fixture
def Query(EventType):
"""
Note that we have to use a custom resolver to replicate the arrayfield filter behavior as
we are running unit tests in sqlite which does not have ArrayFields.
"""
events = [
Event(name="Live Show", tags=["concert", "music", "rock"]),
Event(name="Musical", tags=["movie", "music"]),
Event(name="Ballet", tags=["concert", "dance"]),
Event(name="Speech", tags=[]),
]
class Query(graphene.ObjectType):
events = DjangoFilterConnectionField(EventType)
def resolve_events(self, info, **kwargs):
class FakeQuerySet(QuerySet):
def __init__(self, model=None):
self.model = Event
self.__store = list(events)
def all(self):
return self
def filter(self, **kwargs):
queryset = FakeQuerySet()
queryset.__store = list(self.__store)
if "tags__contains" in kwargs:
queryset.__store = list(
filter(
lambda e: set(kwargs["tags__contains"]).issubset(
set(e.tags)
),
queryset.__store,
)
)
if "tags__overlap" in kwargs:
queryset.__store = list(
filter(
lambda e: not set(kwargs["tags__overlap"]).isdisjoint(
set(e.tags)
),
queryset.__store,
)
)
if "tags__exact" in kwargs:
queryset.__store = list(
filter(
lambda e: set(kwargs["tags__exact"]) == set(e.tags),
queryset.__store,
)
)
if "tags__len" in kwargs:
queryset.__store = list(
filter(
lambda e: len(e.tags) == kwargs["tags__len"],
queryset.__store,
)
)
return queryset
def union(self, *args):
queryset = FakeQuerySet()
queryset.__store = self.__store
for arg in args:
queryset.__store += arg.__store
return queryset
def none(self):
queryset = FakeQuerySet()
queryset.__store = []
return queryset
def count(self):
return len(self.__store)
def distinct(self):
queryset = FakeQuerySet()
queryset.__store = []
for event in self.__store:
if event not in queryset.__store:
queryset.__store.append(event)
queryset.__store = sorted(queryset.__store, key=lambda e: e.name)
return queryset
def __getitem__(self, index):
return self.__store[index]
return FakeQuerySet()
return Query
@pytest.fixture
def schema(Query):
return graphene.Schema(query=Query)

View File

@ -10,7 +10,7 @@ class ArticleFilter(django_filters.FilterSet):
fields = {
"headline": ["exact", "icontains"],
"pub_date": ["gt", "lt", "exact"],
"reporter": ["exact"],
"reporter": ["exact", "in"],
}
order_by = OrderingFilter(fields=("pub_date",))

View File

@ -0,0 +1,79 @@
import pytest
from ...compat import ArrayField, MissingType
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_contains_multiple(schema):
"""
Test contains filter on a array field of string.
"""
query = """
query {
events (tags_Contains: ["concert", "music"]) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == [
{"node": {"name": "Live Show"}},
]
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_contains_one(schema):
"""
Test contains filter on a array field of string.
"""
query = """
query {
events (tags_Contains: ["music"]) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == [
{"node": {"name": "Live Show"}},
{"node": {"name": "Musical"}},
]
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_contains_empty_list(schema):
"""
Test contains filter on a array field of string.
"""
query = """
query {
events (tags_Contains: []) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == [
{"node": {"name": "Live Show"}},
{"node": {"name": "Musical"}},
{"node": {"name": "Ballet"}},
{"node": {"name": "Speech"}},
]

View 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"

View File

@ -0,0 +1,120 @@
import pytest
from ...compat import ArrayField, MissingType
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_exact_no_match(schema):
"""
Test exact filter on a array field of string.
"""
query = """
query {
events (tags: ["concert", "music"]) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == []
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_exact_match(schema):
"""
Test exact filter on a array field of string.
"""
query = """
query {
events (tags: ["movie", "music"]) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == [
{"node": {"name": "Musical"}},
]
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_exact_empty_list(schema):
"""
Test exact filter on a array field of string.
"""
query = """
query {
events (tags: []) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == [
{"node": {"name": "Speech"}},
]
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_filter_schema_type(schema):
"""
Check that the type in the filter is an array field like on the object type.
"""
schema_str = str(schema)
assert (
'''type EventType implements Node {
"""The ID of the object"""
id: ID!
name: String!
tags: [String!]!
tagIds: [Int!]!
randomField: [Boolean!]!
}'''
in schema_str
)
filters = {
"offset": "Int",
"before": "String",
"after": "String",
"first": "Int",
"last": "Int",
"name": "String",
"name_Contains": "String",
"tags_Contains": "[String!]",
"tags_Overlap": "[String!]",
"tags": "[String!]",
"tags_Len": "Int",
"tags_Len_In": "[Int]",
"tagsIds_Contains": "[Int!]",
"tagsIds_Overlap": "[Int!]",
"tagsIds": "[Int!]",
"randomField_Contains": "[Boolean!]",
"randomField_Overlap": "[Boolean!]",
"randomField": "[Boolean!]",
}
filters_str = ", ".join(
[f"{filter_field}: {gql_type}" for filter_field, gql_type in filters.items()]
)
assert (
f"type Query {{\n events({filters_str}): EventTypeConnection\n}}" in schema_str
)

View File

@ -0,0 +1,76 @@
import pytest
from ...compat import ArrayField, MissingType
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_overlap_multiple(schema):
"""
Test overlap filter on a array field of string.
"""
query = """
query {
events (tags_Overlap: ["concert", "music"]) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == [
{"node": {"name": "Live Show"}},
{"node": {"name": "Musical"}},
{"node": {"name": "Ballet"}},
]
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_overlap_one(schema):
"""
Test overlap filter on a array field of string.
"""
query = """
query {
events (tags_Overlap: ["music"]) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == [
{"node": {"name": "Live Show"}},
{"node": {"name": "Musical"}},
]
@pytest.mark.skipif(ArrayField is MissingType, reason="ArrayField should exist")
def test_array_field_overlap_empty_list(schema):
"""
Test overlap filter on a array field of string.
"""
query = """
query {
events (tags_Overlap: []) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["events"]["edges"] == []

View File

@ -0,0 +1,162 @@
import pytest
import graphene
from graphene.relay import Node
from graphene_django import DjangoConnectionField, DjangoObjectType
from graphene_django.tests.models import Article, Reporter
from graphene_django.utils import DJANGO_FILTER_INSTALLED
pytestmark = []
if DJANGO_FILTER_INSTALLED:
from graphene_django.filter import DjangoFilterConnectionField
else:
pytestmark.append(
pytest.mark.skipif(
True, reason="django_filters not installed or not compatible"
)
)
@pytest.fixture
def schema():
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
class ArticleType(DjangoObjectType):
class Meta:
model = Article
interfaces = (Node,)
fields = "__all__"
filter_fields = {
"lang": ["exact", "in"],
"reporter__a_choice": ["exact", "in"],
}
class Query(graphene.ObjectType):
all_reporters = DjangoConnectionField(ReporterType)
all_articles = DjangoFilterConnectionField(ArticleType)
schema = graphene.Schema(query=Query)
return schema
@pytest.fixture
def reporter_article_data():
john = Reporter.objects.create(
first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1
)
jane = Reporter.objects.create(
first_name="Jane", last_name="Doe", email="janedoe@example.com", a_choice=2
)
Article.objects.create(
headline="Article Node 1", reporter=john, editor=john, lang="es"
)
Article.objects.create(
headline="Article Node 2", reporter=john, editor=john, lang="en"
)
Article.objects.create(
headline="Article Node 3", reporter=jane, editor=jane, lang="en"
)
def test_filter_enum_on_connection(schema, reporter_article_data):
"""
Check that we can filter with enums on a connection.
"""
query = """
query {
allArticles(lang: ES) {
edges {
node {
headline
}
}
}
}
"""
expected = {
"allArticles": {
"edges": [
{"node": {"headline": "Article Node 1"}},
]
}
}
result = schema.execute(query)
assert not result.errors
assert result.data == expected
def test_filter_on_foreign_key_enum_field(schema, reporter_article_data):
"""
Check that we can filter with enums on a field from a foreign key.
"""
query = """
query {
allArticles(reporter_AChoice: A_1) {
edges {
node {
headline
}
}
}
}
"""
expected = {
"allArticles": {
"edges": [
{"node": {"headline": "Article Node 1"}},
{"node": {"headline": "Article Node 2"}},
]
}
}
result = schema.execute(query)
assert not result.errors
assert result.data == expected
def test_filter_enum_field_schema_type(schema):
"""
Check that the type in the filter is an enum like on the object type.
"""
schema_str = str(schema)
assert (
'''type ArticleType implements Node {
"""The ID of the object"""
id: ID!
headline: String!
pubDate: Date!
pubDateTime: DateTime!
reporter: ReporterType!
editor: ReporterType!
"""Language"""
lang: TestsArticleLangChoices!
importance: TestsArticleImportanceChoices
}'''
in schema_str
)
filters = {
"offset": "Int",
"before": "String",
"after": "String",
"first": "Int",
"last": "Int",
"lang": "TestsArticleLangChoices",
"lang_In": "[TestsArticleLangChoices]",
"reporter_AChoice": "TestsReporterAChoiceChoices",
"reporter_AChoice_In": "[TestsReporterAChoiceChoices]",
}
filters_str = ", ".join(
[f"{filter_field}: {gql_type}" for filter_field, gql_type in filters.items()]
)
assert f" allArticles({filters_str}): ArticleTypeConnection\n" in schema_str

View File

@ -5,22 +5,22 @@ import pytest
from django.db.models import TextField, Value
from django.db.models.functions import Concat
from graphene import Argument, Boolean, Field, Float, ObjectType, Schema, String
from graphene import Argument, Boolean, Decimal, Field, ObjectType, Schema, String
from graphene.relay import Node
from graphene_django import DjangoObjectType
from graphene_django.forms import GlobalIDFormField, GlobalIDMultipleChoiceField
from graphene_django.tests.models import Article, Pet, Reporter
from graphene_django.tests.models import Article, Person, Pet, Reporter
from graphene_django.utils import DJANGO_FILTER_INSTALLED
pytestmark = []
if DJANGO_FILTER_INSTALLED:
import django_filters
from django_filters import FilterSet, NumberFilter
from django_filters import FilterSet, NumberFilter, OrderingFilter
from graphene_django.filter import (
GlobalIDFilter,
DjangoFilterConnectionField,
GlobalIDFilter,
GlobalIDMultipleChoiceFilter,
)
from graphene_django.filter.tests.filters import (
@ -41,17 +41,20 @@ if DJANGO_FILTER_INSTALLED:
class Meta:
model = Article
interfaces = (Node,)
fields = "__all__"
filter_fields = ("headline",)
class ReporterNode(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
class PetNode(DjangoObjectType):
class Meta:
model = Pet
interfaces = (Node,)
fields = "__all__"
def get_args(field):
@ -64,7 +67,7 @@ def assert_arguments(field, *arguments):
actual = [name for name in args if name not in ignore and not name.startswith("_")]
assert set(arguments) == set(
actual
), "Expected arguments ({}) did not match actual ({})".format(arguments, actual)
), f"Expected arguments ({arguments}) did not match actual ({actual})"
def assert_orderable(field):
@ -87,6 +90,7 @@ def test_filter_explicit_filterset_arguments():
"pub_date__gt",
"pub_date__lt",
"reporter",
"reporter__in",
)
@ -137,7 +141,7 @@ def test_filter_shortcut_filterset_context():
@property
def qs(self):
qs = super(ArticleContextFilter, self).qs
qs = super().qs
return qs.filter(reporter=self.request.reporter)
class Query(ObjectType):
@ -162,7 +166,7 @@ def test_filter_shortcut_filterset_context():
editor=r2,
)
class context(object):
class context:
reporter = r2
query = """
@ -189,6 +193,7 @@ def test_filter_filterset_information_on_meta():
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
filter_fields = ["first_name", "articles"]
field = DjangoFilterConnectionField(ReporterFilterNode)
@ -201,12 +206,14 @@ def test_filter_filterset_information_on_meta_related():
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
filter_fields = ["first_name", "articles"]
class ArticleFilterNode(DjangoObjectType):
class Meta:
model = Article
interfaces = (Node,)
fields = "__all__"
filter_fields = ["headline", "reporter"]
class Query(ObjectType):
@ -215,7 +222,7 @@ def test_filter_filterset_information_on_meta_related():
reporter = Field(ReporterFilterNode)
article = Field(ArticleFilterNode)
schema = Schema(query=Query)
Schema(query=Query)
articles_field = ReporterFilterNode._meta.fields["articles"].get_type()
assert_arguments(articles_field, "headline", "reporter")
assert_not_orderable(articles_field)
@ -233,6 +240,7 @@ def test_filter_filterset_class_filter_fields_exception():
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
filterset_class = ReporterFilter
filter_fields = ["first_name", "articles"]
@ -247,6 +255,7 @@ def test_filter_filterset_class_information_on_meta():
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
filterset_class = ReporterFilter
field = DjangoFilterConnectionField(ReporterFilterNode)
@ -269,12 +278,14 @@ def test_filter_filterset_class_information_on_meta_related():
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
filterset_class = ReporterFilter
class ArticleFilterNode(DjangoObjectType):
class Meta:
model = Article
interfaces = (Node,)
fields = "__all__"
filterset_class = ArticleFilter
class Query(ObjectType):
@ -283,7 +294,7 @@ def test_filter_filterset_class_information_on_meta_related():
reporter = Field(ReporterFilterNode)
article = Field(ArticleFilterNode)
schema = Schema(query=Query)
Schema(query=Query)
articles_field = ReporterFilterNode._meta.fields["articles"].get_type()
assert_arguments(articles_field, "headline", "reporter")
assert_not_orderable(articles_field)
@ -294,12 +305,14 @@ def test_filter_filterset_related_results():
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
filter_fields = ["first_name", "articles"]
class ArticleFilterNode(DjangoObjectType):
class Meta:
interfaces = (Node,)
model = Article
fields = "__all__"
filter_fields = ["headline", "reporter"]
class Query(ObjectType):
@ -388,7 +401,7 @@ def test_filterset_descriptions():
field = DjangoFilterConnectionField(ArticleNode, filterset_class=ArticleIdFilter)
max_time = field.args["max_time"]
assert isinstance(max_time, Argument)
assert max_time.type == Float
assert max_time.type == Decimal
assert max_time.description == "The maximum time"
@ -405,12 +418,14 @@ def test_global_id_field_relation_with_filter():
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
filter_fields = ["first_name", "articles"]
class ArticleFilterNode(DjangoObjectType):
class Meta:
model = Article
interfaces = (Node,)
fields = "__all__"
filter_fields = ["headline", "reporter"]
class Query(ObjectType):
@ -460,12 +475,14 @@ def test_global_id_field_relation_with_filter_not_valid_id():
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
filter_fields = ["first_name", "articles"]
class ArticleFilterNode(DjangoObjectType):
class Meta:
model = Article
interfaces = (Node,)
fields = "__all__"
filter_fields = ["headline", "reporter"]
class Query(ObjectType):
@ -559,6 +576,7 @@ def test_filter_filterset_related_results_with_filter():
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
filter_fields = {"first_name": ["icontains"]}
class Query(ObjectType):
@ -604,6 +622,7 @@ def test_recursive_filter_connection():
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
class Query(ObjectType):
all_reporters = DjangoFilterConnectionField(ReporterFilterNode)
@ -629,11 +648,13 @@ def test_should_query_filter_node_limit():
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
class ArticleType(DjangoObjectType):
class Meta:
model = Article
interfaces = (Node,)
fields = "__all__"
filter_fields = ("lang",)
class Query(ObjectType):
@ -676,7 +697,7 @@ def test_should_query_filter_node_limit():
node {
id
firstName
articles(lang: "es") {
articles(lang: ES) {
edges {
node {
id
@ -718,6 +739,7 @@ def test_order_by():
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
class Query(ObjectType):
all_reporters = DjangoFilterConnectionField(
@ -767,7 +789,7 @@ def test_order_by():
query = """
query NodeFilteringQuery {
allReporters(orderBy: "-firtsnaMe") {
allReporters(orderBy: "-firstname") {
edges {
node {
firstName
@ -780,11 +802,12 @@ def test_order_by():
assert result.errors
def test_order_by_is_perserved():
def test_order_by_is_preserved():
class ReporterType(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
filter_fields = ()
class Query(ObjectType):
@ -851,6 +874,7 @@ def test_annotation_is_preserved():
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
filter_fields = ()
class Query(ObjectType):
@ -893,6 +917,7 @@ def test_annotation_with_only():
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
filter_fields = ()
class Query(ObjectType):
@ -933,6 +958,7 @@ def test_node_get_queryset_is_called():
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
filter_fields = ()
@classmethod
@ -981,40 +1007,56 @@ def test_integer_field_filter_type():
assert str(schema) == dedent(
"""\
schema {
query: Query
type Query {
pets(offset: Int, before: String, after: String, first: Int, last: Int, age: Int): PetTypeConnection
}
interface Node {
id: ID!
type PetTypeConnection {
\"""Pagination data for this connection.\"""
pageInfo: PageInfo!
\"""Contains the nodes in this connection.\"""
edges: [PetTypeEdge]!
}
\"""
The Relay compliant `PageInfo` type, containing data necessary to paginate this connection.
\"""
type PageInfo {
\"""When paginating forwards, are there more items?\"""
hasNextPage: Boolean!
\"""When paginating backwards, are there more items?\"""
hasPreviousPage: Boolean!
\"""When paginating backwards, the cursor to continue.\"""
startCursor: String
\"""When paginating forwards, the cursor to continue.\"""
endCursor: String
}
\"""A Relay edge containing a `PetType` and its cursor.\"""
type PetTypeEdge {
\"""The item at the end of the edge\"""
node: PetType
\"""A cursor for use in pagination\"""
cursor: String!
}
type PetType implements Node {
age: Int!
\"""The ID of the object\"""
id: ID!
}
type PetTypeConnection {
pageInfo: PageInfo!
edges: [PetTypeEdge]!
}
type PetTypeEdge {
node: PetType
cursor: String!
}
type Query {
pets(offset: Int, before: String, after: String, first: Int, last: Int, age: Int): PetTypeConnection
}
"""
\"""An object with an ID\"""
interface Node {
\"""The ID of the object\"""
id: ID!
}"""
)
@ -1033,40 +1075,56 @@ def test_other_filter_types():
assert str(schema) == dedent(
"""\
schema {
query: Query
type Query {
pets(offset: Int, before: String, after: String, first: Int, last: Int, age: Int, age_Isnull: Boolean, age_Lt: Int): PetTypeConnection
}
interface Node {
id: ID!
type PetTypeConnection {
\"""Pagination data for this connection.\"""
pageInfo: PageInfo!
\"""Contains the nodes in this connection.\"""
edges: [PetTypeEdge]!
}
\"""
The Relay compliant `PageInfo` type, containing data necessary to paginate this connection.
\"""
type PageInfo {
\"""When paginating forwards, are there more items?\"""
hasNextPage: Boolean!
\"""When paginating backwards, are there more items?\"""
hasPreviousPage: Boolean!
\"""When paginating backwards, the cursor to continue.\"""
startCursor: String
\"""When paginating forwards, the cursor to continue.\"""
endCursor: String
}
\"""A Relay edge containing a `PetType` and its cursor.\"""
type PetTypeEdge {
\"""The item at the end of the edge\"""
node: PetType
\"""A cursor for use in pagination\"""
cursor: String!
}
type PetType implements Node {
age: Int!
\"""The ID of the object\"""
id: ID!
}
type PetTypeConnection {
pageInfo: PageInfo!
edges: [PetTypeEdge]!
}
type PetTypeEdge {
node: PetType
cursor: String!
}
type Query {
pets(offset: Int, before: String, after: String, first: Int, last: Int, age: Int, age_Isnull: Boolean, age_Lt: Int): PetTypeConnection
}
"""
\"""An object with an ID\"""
interface Node {
\"""The ID of the object\"""
id: ID!
}"""
)
@ -1085,7 +1143,7 @@ def test_filter_filterset_based_on_mixin():
return filters
def filter_email_in(cls, queryset, name, value):
def filter_email_in(self, queryset, name, value):
return queryset.filter(**{name: [value]})
class NewArticleFilter(ArticleFilterMixin, ArticleFilter):
@ -1095,6 +1153,7 @@ def test_filter_filterset_based_on_mixin():
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
class NewArticleFilterNode(DjangoObjectType):
viewer = Field(NewReporterNode)
@ -1102,6 +1161,7 @@ def test_filter_filterset_based_on_mixin():
class Meta:
model = Article
interfaces = (Node,)
fields = "__all__"
filterset_class = NewArticleFilter
def resolve_viewer(self, info):
@ -1126,7 +1186,7 @@ def test_filter_filterset_based_on_mixin():
first_name="Adam", last_name="Doe", email="adam@doe.com"
)
article_2 = Article.objects.create(
Article.objects.create(
headline="Good Bye",
reporter=reporter_2,
editor=reporter_2,
@ -1136,10 +1196,9 @@ def test_filter_filterset_based_on_mixin():
schema = Schema(query=Query)
query = (
"""
query NodeFilteringQuery {
allArticles(viewer_Email_In: "%s") {
query = """
query NodeFilteringQuery ($email: String!) {
allArticles(viewer_Email_In: $email) {
edges {
node {
headline
@ -1151,8 +1210,6 @@ def test_filter_filterset_based_on_mixin():
}
}
"""
% reporter_1.email
)
expected = {
"allArticles": {
@ -1167,7 +1224,81 @@ def test_filter_filterset_based_on_mixin():
}
}
result = schema.execute(query)
result = schema.execute(query, variable_values={"email": reporter_1.email})
assert not result.errors
assert result.data == expected
def test_filter_string_contains():
class PersonType(DjangoObjectType):
class Meta:
model = Person
interfaces = (Node,)
fields = "__all__"
filter_fields = {"name": ["exact", "in", "contains", "icontains"]}
class Query(ObjectType):
people = DjangoFilterConnectionField(PersonType)
schema = Schema(query=Query)
Person.objects.bulk_create(
[
Person(name="Jack"),
Person(name="Joe"),
Person(name="Jane"),
Person(name="Peter"),
Person(name="Bob"),
]
)
query = """query nameContain($filter: String) {
people(name_Contains: $filter) {
edges {
node {
name
}
}
}
}"""
result = schema.execute(query, variables={"filter": "Ja"})
assert not result.errors
assert result.data == {
"people": {
"edges": [
{"node": {"name": "Jack"}},
{"node": {"name": "Jane"}},
]
}
}
result = schema.execute(query, variables={"filter": "o"})
assert not result.errors
assert result.data == {
"people": {
"edges": [
{"node": {"name": "Joe"}},
{"node": {"name": "Bob"}},
]
}
}
def test_only_custom_filters():
class ReporterFilter(FilterSet):
class Meta:
model = Reporter
fields = []
some_filter = OrderingFilter(fields=("name",))
class ReporterFilterNode(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
filterset_class = ReporterFilter
field = DjangoFilterConnectionField(ReporterFilterNode)
assert_arguments(field, "some_filter")

View File

@ -1,11 +1,16 @@
import pytest
from datetime import datetime
import pytest
from django_filters import (
FilterSet,
rest_framework as filters,
)
from django_filters import FilterSet
from django_filters import rest_framework as filters
from graphene import ObjectType, Schema
from graphene.relay import Node
from graphene_django import DjangoObjectType
from graphene_django.tests.models import Pet, Person
from graphene_django.filter.tests.filters import ArticleFilter
from graphene_django.tests.models import Article, Film, Person, Pet, Reporter
from graphene_django.utils import DJANGO_FILTER_INSTALLED
pytestmark = []
@ -20,40 +25,77 @@ else:
)
class PetNode(DjangoObjectType):
class Meta:
model = Pet
interfaces = (Node,)
filter_fields = {
"name": ["exact", "in"],
"age": ["exact", "in", "range"],
}
@pytest.fixture
def query():
class PetNode(DjangoObjectType):
class Meta:
model = Pet
interfaces = (Node,)
fields = "__all__"
filter_fields = {
"id": ["exact", "in"],
"name": ["exact", "in"],
"age": ["exact", "in", "range"],
}
class ReporterNode(DjangoObjectType):
class Meta:
model = Reporter
interfaces = (Node,)
fields = "__all__"
# choice filter using enum
filter_fields = {"reporter_type": ["exact", "in"]}
class ArticleNode(DjangoObjectType):
class Meta:
model = Article
interfaces = (Node,)
fields = "__all__"
filterset_class = ArticleFilter
class FilmNode(DjangoObjectType):
class Meta:
model = Film
interfaces = (Node,)
fields = "__all__"
# choice filter not using enum
filter_fields = {
"genre": ["exact", "in"],
}
convert_choices_to_enum = False
class PersonFilterSet(FilterSet):
class Meta:
model = Person
fields = {"name": ["in"]}
names = filters.BaseInFilter(method="filter_names")
def filter_names(self, qs, name, value):
"""
This custom filter take a string as input with comma separated values.
Note that the value here is already a list as it has been transformed by the BaseInFilter class.
"""
return qs.filter(name__in=value)
class PersonNode(DjangoObjectType):
class Meta:
model = Person
interfaces = (Node,)
filterset_class = PersonFilterSet
fields = "__all__"
class Query(ObjectType):
pets = DjangoFilterConnectionField(PetNode)
people = DjangoFilterConnectionField(PersonNode)
articles = DjangoFilterConnectionField(ArticleNode)
films = DjangoFilterConnectionField(FilmNode)
reporters = DjangoFilterConnectionField(ReporterNode)
return Query
class PersonFilterSet(FilterSet):
class Meta:
model = Person
fields = {}
names = filters.BaseInFilter(method="filter_names")
def filter_names(self, qs, name, value):
return qs.filter(name__in=value)
class PersonNode(DjangoObjectType):
class Meta:
model = Person
interfaces = (Node,)
filterset_class = PersonFilterSet
class Query(ObjectType):
pets = DjangoFilterConnectionField(PetNode)
people = DjangoFilterConnectionField(PersonNode)
def test_string_in_filter():
def test_string_in_filter(query):
"""
Test in filter on a string field.
"""
@ -61,7 +103,7 @@ def test_string_in_filter():
Pet.objects.create(name="Mimi", age=3)
Pet.objects.create(name="Jojo, the rabbit", age=3)
schema = Schema(query=Query)
schema = Schema(query=query)
query = """
query {
@ -82,17 +124,19 @@ def test_string_in_filter():
]
def test_string_in_filter_with_filterset_class():
"""Test in filter on a string field with a custom filterset class."""
def test_string_in_filter_with_otjer_filter(query):
"""
Test in filter on a string field which has also a custom filter doing a similar operation.
"""
Person.objects.create(name="John")
Person.objects.create(name="Michael")
Person.objects.create(name="Angela")
schema = Schema(query=Query)
schema = Schema(query=query)
query = """
query {
people (names: ["John", "Michael"]) {
people (name_In: ["John", "Michael"]) {
edges {
node {
name
@ -109,7 +153,36 @@ def test_string_in_filter_with_filterset_class():
]
def test_int_in_filter():
def test_string_in_filter_with_declared_filter(query):
"""
Test in filter on a string field with a custom filterset class.
"""
Person.objects.create(name="John")
Person.objects.create(name="Michael")
Person.objects.create(name="Angela")
schema = Schema(query=query)
query = """
query {
people (names: "John,Michael") {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["people"]["edges"] == [
{"node": {"name": "John"}},
{"node": {"name": "Michael"}},
]
def test_int_in_filter(query):
"""
Test in filter on an integer field.
"""
@ -117,7 +190,7 @@ def test_int_in_filter():
Pet.objects.create(name="Mimi", age=3)
Pet.objects.create(name="Jojo, the rabbit", age=3)
schema = Schema(query=Query)
schema = Schema(query=query)
query = """
query {
@ -157,20 +230,19 @@ def test_int_in_filter():
]
def test_int_range_filter():
def test_in_filter_with_empty_list(query):
"""
Test in filter on an integer field.
Check that using a in filter with an empty list provided as input returns no objects.
"""
Pet.objects.create(name="Brutus", age=12)
Pet.objects.create(name="Mimi", age=8)
Pet.objects.create(name="Jojo, the rabbit", age=3)
Pet.objects.create(name="Picotin", age=5)
schema = Schema(query=Query)
schema = Schema(query=query)
query = """
query {
pets (age_Range: [4, 9]) {
pets (name_In: []) {
edges {
node {
name
@ -181,7 +253,195 @@ def test_int_range_filter():
"""
result = schema.execute(query)
assert not result.errors
assert result.data["pets"]["edges"] == [
{"node": {"name": "Mimi"}},
{"node": {"name": "Picotin"}},
assert len(result.data["pets"]["edges"]) == 0
def test_choice_in_filter_without_enum(query):
"""
Test in filter o an choice field not using an enum (Film.genre).
"""
john_doe = Reporter.objects.create(
first_name="John", last_name="Doe", email="john@doe.com"
)
jean_bon = Reporter.objects.create(
first_name="Jean", last_name="Bon", email="jean@bon.com"
)
documentary_film = Film.objects.create(genre="do")
documentary_film.reporters.add(john_doe)
action_film = Film.objects.create(genre="ac")
action_film.reporters.add(john_doe)
other_film = Film.objects.create(genre="ot")
other_film.reporters.add(john_doe)
other_film.reporters.add(jean_bon)
schema = Schema(query=query)
query = """
query {
films (genre_In: ["do", "ac"]) {
edges {
node {
genre
reporters {
edges {
node {
lastName
}
}
}
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["films"]["edges"] == [
{
"node": {
"genre": "do",
"reporters": {"edges": [{"node": {"lastName": "Doe"}}]},
}
},
{
"node": {
"genre": "ac",
"reporters": {"edges": [{"node": {"lastName": "Doe"}}]},
}
},
]
def test_fk_id_in_filter(query):
"""
Test in filter on an foreign key relationship.
"""
john_doe = Reporter.objects.create(
first_name="John", last_name="Doe", email="john@doe.com"
)
jean_bon = Reporter.objects.create(
first_name="Jean", last_name="Bon", email="jean@bon.com"
)
sara_croche = Reporter.objects.create(
first_name="Sara", last_name="Croche", email="sara@croche.com"
)
Article.objects.create(
headline="A",
pub_date=datetime.now(),
pub_date_time=datetime.now(),
reporter=john_doe,
editor=john_doe,
)
Article.objects.create(
headline="B",
pub_date=datetime.now(),
pub_date_time=datetime.now(),
reporter=jean_bon,
editor=jean_bon,
)
Article.objects.create(
headline="C",
pub_date=datetime.now(),
pub_date_time=datetime.now(),
reporter=sara_croche,
editor=sara_croche,
)
schema = Schema(query=query)
query = f"""
query {{
articles (reporter_In: [{john_doe.id}, {jean_bon.id}]) {{
edges {{
node {{
headline
reporter {{
lastName
}}
}}
}}
}}
}}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A", "reporter": {"lastName": "Doe"}}},
{"node": {"headline": "B", "reporter": {"lastName": "Bon"}}},
]
def test_enum_in_filter(query):
"""
Test in filter on a choice field using an enum (Reporter.reporter_type).
"""
Reporter.objects.create(
first_name="John", last_name="Doe", email="john@doe.com", reporter_type=1
)
Reporter.objects.create(
first_name="Jean", last_name="Bon", email="jean@bon.com", reporter_type=2
)
Reporter.objects.create(
first_name="Jane", last_name="Doe", email="jane@doe.com", reporter_type=2
)
Reporter.objects.create(
first_name="Jack", last_name="Black", email="jack@black.com", reporter_type=None
)
schema = Schema(query=query)
query = """
query {
reporters (reporterType_In: [A_1]) {
edges {
node {
email
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["reporters"]["edges"] == [
{"node": {"email": "john@doe.com"}},
]
query = """
query {
reporters (reporterType_In: [A_2]) {
edges {
node {
email
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["reporters"]["edges"] == [
{"node": {"email": "jean@bon.com"}},
{"node": {"email": "jane@doe.com"}},
]
query = """
query {
reporters (reporterType_In: [A_2, A_1]) {
edges {
node {
email
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["reporters"]["edges"] == [
{"node": {"email": "john@doe.com"}},
{"node": {"email": "jean@bon.com"}},
{"node": {"email": "jane@doe.com"}},
]

View File

@ -0,0 +1,114 @@
import json
import pytest
from graphene import ObjectType, Schema
from graphene.relay import Node
from graphene_django import DjangoObjectType
from graphene_django.tests.models import Pet
from graphene_django.utils import DJANGO_FILTER_INSTALLED
pytestmark = []
if DJANGO_FILTER_INSTALLED:
from graphene_django.filter import DjangoFilterConnectionField
else:
pytestmark.append(
pytest.mark.skipif(
True, reason="django_filters not installed or not compatible"
)
)
class PetNode(DjangoObjectType):
class Meta:
model = Pet
interfaces = (Node,)
fields = "__all__"
filter_fields = {
"name": ["exact", "in"],
"age": ["exact", "in", "range"],
}
class Query(ObjectType):
pets = DjangoFilterConnectionField(PetNode)
def test_int_range_filter():
"""
Test range filter on an integer field.
"""
Pet.objects.create(name="Brutus", age=12)
Pet.objects.create(name="Mimi", age=8)
Pet.objects.create(name="Jojo, the rabbit", age=3)
Pet.objects.create(name="Picotin", age=5)
schema = Schema(query=Query)
query = """
query {
pets (age_Range: [4, 9]) {
edges {
node {
name
}
}
}
}
"""
result = schema.execute(query)
assert not result.errors
assert result.data["pets"]["edges"] == [
{"node": {"name": "Mimi"}},
{"node": {"name": "Picotin"}},
]
def test_range_filter_with_invalid_input():
"""
Test range filter used with invalid inputs raise an error.
"""
Pet.objects.create(name="Brutus", age=12)
Pet.objects.create(name="Mimi", age=8)
Pet.objects.create(name="Jojo, the rabbit", age=3)
Pet.objects.create(name="Picotin", age=5)
schema = Schema(query=Query)
query = """
query ($rangeValue: [Int]) {
pets (age_Range: $rangeValue) {
edges {
node {
name
}
}
}
}
"""
expected_error = json.dumps(
{
"age__range": [
{
"message": "Invalid range specified: it needs to contain 2 values.",
"code": "invalid",
}
]
}
)
# Empty list
result = schema.execute(query, variables={"rangeValue": []})
assert len(result.errors) == 1
assert result.errors[0].message == expected_error
# Only one item in the list
result = schema.execute(query, variables={"rangeValue": [1]})
assert len(result.errors) == 1
assert result.errors[0].message == expected_error
# More than 2 items in the list
result = schema.execute(query, variables={"rangeValue": [1, 2, 3]})
assert len(result.errors) == 1
assert result.errors[0].message == expected_error

View File

@ -0,0 +1,231 @@
import operator
from functools import reduce
import pytest
from django.db.models import Q
from django_filters import FilterSet
import graphene
from graphene.relay import Node
from graphene_django import DjangoObjectType
from graphene_django.tests.models import Article, Reporter
from graphene_django.utils import DJANGO_FILTER_INSTALLED
pytestmark = []
if DJANGO_FILTER_INSTALLED:
from graphene_django.filter import (
DjangoFilterConnectionField,
ListFilter,
TypedFilter,
)
else:
pytestmark.append(
pytest.mark.skipif(
True, reason="django_filters not installed or not compatible"
)
)
@pytest.fixture
def schema():
class ArticleFilterSet(FilterSet):
class Meta:
model = Article
fields = {
"lang": ["exact", "in"],
}
lang__contains = TypedFilter(
field_name="lang", lookup_expr="icontains", input_type=graphene.String
)
lang__in_str = ListFilter(
field_name="lang",
lookup_expr="in",
input_type=graphene.List(graphene.String),
)
first_n = TypedFilter(input_type=graphene.Int, method="first_n_filter")
only_first = TypedFilter(
input_type=graphene.Boolean, method="only_first_filter"
)
headline_search = ListFilter(
method="headline_search_filter",
input_type=graphene.List(graphene.String),
)
def first_n_filter(self, queryset, _name, value):
return queryset[:value]
def only_first_filter(self, queryset, _name, value):
if value:
return queryset[:1]
else:
return queryset
def headline_search_filter(self, queryset, _name, value):
if not value:
return queryset.none()
return queryset.filter(
reduce(operator.or_, [Q(headline__icontains=v) for v in value])
)
class ArticleType(DjangoObjectType):
class Meta:
model = Article
interfaces = (Node,)
fields = "__all__"
filterset_class = ArticleFilterSet
class Query(graphene.ObjectType):
articles = DjangoFilterConnectionField(ArticleType)
schema = graphene.Schema(query=Query)
return schema
def test_typed_filter_schema(schema):
"""
Check that the type provided in the filter is reflected in the schema.
"""
schema_str = str(schema)
filters = {
"offset": "Int",
"before": "String",
"after": "String",
"first": "Int",
"last": "Int",
"lang": "TestsArticleLangChoices",
"lang_In": "[TestsArticleLangChoices]",
"lang_Contains": "String",
"lang_InStr": "[String]",
"firstN": "Int",
"onlyFirst": "Boolean",
"headlineSearch": "[String]",
}
all_articles_filters = (
schema_str.split(" articles(")[1]
.split("): ArticleTypeConnection\n")[0]
.split(", ")
)
for filter_field, gql_type in filters.items():
assert f"{filter_field}: {gql_type}" in all_articles_filters
def test_typed_filters_work(schema):
reporter = Reporter.objects.create(first_name="John", last_name="Doe", email="")
Article.objects.create(headline="A", reporter=reporter, editor=reporter, lang="es")
Article.objects.create(headline="B", reporter=reporter, editor=reporter, lang="es")
Article.objects.create(headline="C", reporter=reporter, editor=reporter, lang="en")
Article.objects.create(headline="AB", reporter=reporter, editor=reporter, lang="es")
query = 'query { articles (lang_Contains: "n") { edges { node { headline } } } }'
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "C"}},
]
query = "query { articles (firstN: 2) { edges { node { headline } } } }"
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
{"node": {"headline": "AB"}},
]
query = "query { articles (onlyFirst: true) { edges { node { headline } } } }"
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
]
def test_list_filters_work(schema):
reporter = Reporter.objects.create(first_name="John", last_name="Doe", email="")
Article.objects.create(headline="A", reporter=reporter, editor=reporter, lang="es")
Article.objects.create(headline="B", reporter=reporter, editor=reporter, lang="es")
Article.objects.create(headline="C", reporter=reporter, editor=reporter, lang="en")
Article.objects.create(headline="AB", reporter=reporter, editor=reporter, lang="es")
query = "query { articles (lang_In: [ES]) { edges { node { headline } } } }"
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
{"node": {"headline": "AB"}},
{"node": {"headline": "B"}},
]
query = 'query { articles (lang_InStr: ["es"]) { edges { node { headline } } } }'
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
{"node": {"headline": "AB"}},
{"node": {"headline": "B"}},
]
query = "query { articles (lang_InStr: []) { edges { node { headline } } } }"
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == []
query = "query { articles (lang_InStr: null) { edges { node { headline } } } }"
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
{"node": {"headline": "AB"}},
{"node": {"headline": "B"}},
{"node": {"headline": "C"}},
]
query = 'query { articles (headlineSearch: ["a", "B"]) { edges { node { headline } } } }'
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
{"node": {"headline": "AB"}},
{"node": {"headline": "B"}},
]
query = "query { articles (headlineSearch: []) { edges { node { headline } } } }"
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == []
query = "query { articles (headlineSearch: null) { edges { node { headline } } } }"
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
{"node": {"headline": "AB"}},
{"node": {"headline": "B"}},
{"node": {"headline": "C"}},
]
query = 'query { articles (headlineSearch: [""]) { edges { node { headline } } } }'
result = schema.execute(query)
assert not result.errors
assert result.data["articles"]["edges"] == [
{"node": {"headline": "A"}},
{"node": {"headline": "AB"}},
{"node": {"headline": "B"}},
{"node": {"headline": "C"}},
]

View File

@ -1,54 +1,104 @@
import six
from graphene import List
from django import forms
from django_filters.utils import get_model_field
from django_filters.filters import Filter, BaseCSVFilter
import graphene
from ..forms import GlobalIDFormField, GlobalIDMultipleChoiceField
from .filters import ListFilter, RangeFilter, TypedFilter
from .filterset import custom_filterset_factory, setup_filterset
def get_field_type(registry, model, field_name):
"""
Try to get a model field corresponding Graphql type from the DjangoObjectType.
"""
object_type = registry.get_type_for_model(model)
if object_type:
object_type_field = object_type._meta.fields.get(field_name)
if object_type_field:
field_type = object_type_field.type
if isinstance(field_type, graphene.NonNull):
field_type = field_type.of_type
return field_type
return None
def get_filtering_args_from_filterset(filterset_class, type):
""" Inspect a FilterSet and produce the arguments to pass to
a Graphene Field. These arguments will be available to
filter against in the GraphQL
"""
Inspect a FilterSet and produce the arguments to pass to a Graphene Field.
These arguments will be available to filter against in the GraphQL API.
"""
from ..forms.converter import convert_form_field
args = {}
model = filterset_class._meta.model
for name, filter_field in six.iteritems(filterset_class.base_filters):
form_field = None
registry = type._meta.registry
for name, filter_field in filterset_class.base_filters.items():
filter_type = filter_field.lookup_expr
required = filter_field.extra.get("required", False)
field_type = None
form_field = None
if name in filterset_class.declared_filters:
# Get the filter field from the explicitly declared filter
form_field = filter_field.field
field = convert_form_field(form_field)
if (
isinstance(filter_field, TypedFilter)
and filter_field.input_type is not None
):
# First check if the filter input type has been explicitly given
field_type = filter_field.input_type
else:
# Get the filter field with no explicit type declaration
model_field = get_model_field(model, filter_field.field_name)
if filter_type != "isnull" and hasattr(model_field, "formfield"):
form_field = model_field.formfield(
required=filter_field.extra.get("required", False)
)
if name not in filterset_class.declared_filters or isinstance(
filter_field, TypedFilter
):
# Get the filter field for filters that are no explicitly declared.
if filter_type == "isnull":
field_type = graphene.Boolean
else:
model_field = get_model_field(model, filter_field.field_name)
# Fallback to field defined on filter if we can't get it from the
# model field
if not form_field:
form_field = filter_field.field
# Get the form field either from:
# 1. the formfield corresponding to the model field
# 2. the field defined on filter
if hasattr(model_field, "formfield"):
form_field = model_field.formfield(required=required)
if not form_field:
form_field = filter_field.field
field = convert_form_field(form_field)
# First try to get the matching field type from the GraphQL DjangoObjectType
if model_field:
if (
isinstance(form_field, forms.ModelChoiceField)
or isinstance(form_field, forms.ModelMultipleChoiceField)
or isinstance(form_field, GlobalIDMultipleChoiceField)
or isinstance(form_field, GlobalIDFormField)
):
# Foreign key have dynamic types and filtering on a foreign key actually means filtering on its ID.
field_type = get_field_type(
registry, model_field.related_model, "id"
)
else:
field_type = get_field_type(
registry, model_field.model, model_field.name
)
if filter_type in ["in", "range"]:
# Replace CSV filters (`in`, `range`) argument type to be a list of
# the same type as the field. See comments in
# `replace_csv_filters` method for more details.
field = List(field.get_type())
if not field_type:
# Fallback on converting the form field either because:
# - it's an explicitly declared filters
# - we did not manage to get the type from the model type
form_field = form_field or filter_field.field
field_type = convert_form_field(form_field).get_type()
field_type = field.Argument()
field_type.description = filter_field.label
args[name] = field_type
if isinstance(filter_field, ListFilter) or isinstance(
filter_field, RangeFilter
):
# Replace InFilter/RangeFilter filters (`in`, `range`) argument type to be a list of
# the same type as the field. See comments in `replace_csv_filters` method for more details.
field_type = graphene.List(field_type)
args[name] = graphene.Argument(
field_type,
description=filter_field.label,
required=required,
)
return args
@ -70,23 +120,39 @@ def get_filterset_class(filterset_class, **meta):
def replace_csv_filters(filterset_class):
"""
Replace the "in" and "range" filters (that are not explicitly declared) to not be BaseCSVFilter (BaseInFilter, BaseRangeFilter) objects anymore
but regular Filter objects that simply use the input value as filter argument on the queryset.
Replace the "in" and "range" filters (that are not explicitly declared)
to not be BaseCSVFilter (BaseInFilter, BaseRangeFilter) objects anymore
but our custom InFilter/RangeFilter filter class that use the input
value as filter argument on the queryset.
This is because those BaseCSVFilter are expecting a string as input with comma separated value but with GraphQl we
can actually have a list as input and have a proper type verification of each value in the list.
This is because those BaseCSVFilter are expecting a string as input with
comma separated values.
But with GraphQl we can actually have a list as input and have a proper
type verification of each value in the list.
See issue https://github.com/graphql-python/graphene-django/issues/1068.
"""
for name, filter_field in six.iteritems(filterset_class.base_filters):
for name, filter_field in list(filterset_class.base_filters.items()):
# Do not touch any declared filters
if name in filterset_class.declared_filters:
continue
filter_type = filter_field.lookup_expr
if filter_type in ["in", "range"]:
assert isinstance(filter_field, BaseCSVFilter)
filterset_class.base_filters[name] = Filter(
if filter_type == "in":
filterset_class.base_filters[name] = ListFilter(
field_name=filter_field.field_name,
lookup_expr=filter_field.lookup_expr,
label=filter_field.label,
method=filter_field.method,
exclude=filter_field.exclude,
**filter_field.extra
**filter_field.extra,
)
elif filter_type == "range":
filterset_class.base_filters[name] = RangeFilter(
field_name=filter_field.field_name,
lookup_expr=filter_field.lookup_expr,
label=filter_field.label,
method=filter_field.method,
exclude=filter_field.exclude,
**filter_field.extra,
)

View File

@ -1,20 +1,34 @@
from functools import singledispatch
from django import forms
from django.core.exceptions import ImproperlyConfigured
from graphene import ID, Boolean, Float, Int, List, String, UUID, Date, DateTime, Time
from graphene import (
ID,
UUID,
Boolean,
Date,
DateTime,
Decimal,
Float,
Int,
List,
String,
Time,
)
from .forms import GlobalIDFormField, GlobalIDMultipleChoiceField
from ..utils import import_single_dispatch
singledispatch = import_single_dispatch()
def get_form_field_description(field):
return str(field.help_text) if field.help_text else None
@singledispatch
def convert_form_field(field):
raise ImproperlyConfigured(
"Don't know how to convert the Django form field %s (%s) "
"to Graphene type" % (field, field.__class__)
f"Don't know how to convert the Django form field {field} ({field.__class__}) "
"to Graphene type"
)
@ -27,60 +41,74 @@ def convert_form_field(field):
@convert_form_field.register(forms.RegexField)
@convert_form_field.register(forms.Field)
def convert_form_field_to_string(field):
return String(description=field.help_text, required=field.required)
return String(
description=get_form_field_description(field), required=field.required
)
@convert_form_field.register(forms.UUIDField)
def convert_form_field_to_uuid(field):
return UUID(description=field.help_text, required=field.required)
return UUID(description=get_form_field_description(field), required=field.required)
@convert_form_field.register(forms.IntegerField)
@convert_form_field.register(forms.NumberInput)
def convert_form_field_to_int(field):
return Int(description=field.help_text, required=field.required)
return Int(description=get_form_field_description(field), required=field.required)
@convert_form_field.register(forms.BooleanField)
def convert_form_field_to_boolean(field):
return Boolean(description=field.help_text, required=field.required)
return Boolean(
description=get_form_field_description(field), required=field.required
)
@convert_form_field.register(forms.NullBooleanField)
def convert_form_field_to_nullboolean(field):
return Boolean(description=field.help_text)
return Boolean(description=get_form_field_description(field))
@convert_form_field.register(forms.FloatField)
def convert_form_field_to_float(field):
return Float(description=get_form_field_description(field), required=field.required)
@convert_form_field.register(forms.DecimalField)
@convert_form_field.register(forms.FloatField)
def convert_form_field_to_float(field):
return Float(description=field.help_text, required=field.required)
@convert_form_field.register(forms.ModelMultipleChoiceField)
@convert_form_field.register(GlobalIDMultipleChoiceField)
def convert_form_field_to_list(field):
return List(ID, required=field.required)
def convert_form_field_to_decimal(field):
return Decimal(
description=get_form_field_description(field), required=field.required
)
@convert_form_field.register(forms.MultipleChoiceField)
def convert_form_field_to_string_list(field):
return List(String, required=field.required)
return List(
String, description=get_form_field_description(field), required=field.required
)
@convert_form_field.register(forms.ModelMultipleChoiceField)
@convert_form_field.register(GlobalIDMultipleChoiceField)
def convert_form_field_to_id_list(field):
return List(ID, required=field.required)
@convert_form_field.register(forms.DateField)
def convert_form_field_to_date(field):
return Date(description=field.help_text, required=field.required)
return Date(description=get_form_field_description(field), required=field.required)
@convert_form_field.register(forms.DateTimeField)
def convert_form_field_to_datetime(field):
return DateTime(description=field.help_text, required=field.required)
return DateTime(
description=get_form_field_description(field), required=field.required
)
@convert_form_field.register(forms.TimeField)
def convert_form_field_to_time(field):
return Time(description=field.help_text, required=field.required)
return Time(description=get_form_field_description(field), required=field.required)
@convert_form_field.register(forms.ModelChoiceField)

View File

@ -3,7 +3,6 @@ import binascii
from django.core.exceptions import ValidationError
from django.forms import CharField, Field, MultipleChoiceField
from django.utils.translation import gettext_lazy as _
from graphql_relay import from_global_id

View File

@ -14,10 +14,6 @@ from graphene.types.utils import yank_fields_from_attrs
from graphene_django.constants import MUTATION_ERRORS_FLAG
from graphene_django.registry import get_global_registry
from django.core.exceptions import ValidationError
from django.db import connection
from ..types import ErrorType
from .converter import convert_form_field
@ -27,8 +23,7 @@ def fields_for_form(form, only_fields, exclude_fields):
for name, field in form.fields.items():
is_not_in_only = only_fields and name not in only_fields
is_excluded = (
name
in exclude_fields # or
name in exclude_fields # or
# name in already_created_fields
)
@ -86,7 +81,6 @@ class DjangoFormMutation(BaseDjangoFormMutation):
def __init_subclass_with_meta__(
cls, form_class=None, only_fields=(), exclude_fields=(), **options
):
if not form_class:
raise Exception("form_class is required for DjangoFormMutation")
@ -99,13 +93,16 @@ class DjangoFormMutation(BaseDjangoFormMutation):
_meta.fields = yank_fields_from_attrs(output_fields, _as=Field)
input_fields = yank_fields_from_attrs(input_fields, _as=InputField)
super(DjangoFormMutation, cls).__init_subclass_with_meta__(
super().__init_subclass_with_meta__(
_meta=_meta, input_fields=input_fields, **options
)
@classmethod
def perform_mutate(cls, form, info):
form.save()
if hasattr(form, "save"):
# `save` method won't exist on plain Django forms, but this mutation can
# in theory be used with `ModelForm`s as well and we do want to save them.
form.save()
return cls(errors=[], **form.cleaned_data)
@ -118,7 +115,7 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
class Meta:
abstract = True
errors = graphene.List(ErrorType)
errors = graphene.List(graphene.NonNull(ErrorType), required=True)
@classmethod
def __init_subclass_with_meta__(
@ -128,9 +125,8 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
return_field_name=None,
only_fields=(),
exclude_fields=(),
**options
**options,
):
if not form_class:
raise Exception("form_class is required for DjangoModelFormMutation")
@ -148,7 +144,7 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
registry = get_global_registry()
model_type = registry.get_type_for_model(model)
if not model_type:
raise Exception("No type registered for model: {}".format(model.__name__))
raise Exception(f"No type registered for model: {model.__name__}")
if not return_field_name:
model_name = model.__name__
@ -164,7 +160,7 @@ class DjangoModelFormMutation(BaseDjangoFormMutation):
_meta.fields = yank_fields_from_attrs(output_fields, _as=Field)
input_fields = yank_fields_from_attrs(input_fields, _as=InputField)
super(DjangoModelFormMutation, cls).__init_subclass_with_meta__(
super().__init_subclass_with_meta__(
_meta=_meta, input_fields=input_fields, **options
)

View File

@ -1,30 +1,34 @@
from django import forms
from py.test import raises
from django import VERSION as DJANGO_VERSION, forms
from pytest import raises
import graphene
from graphene import (
String,
Int,
Boolean,
Float,
ID,
UUID,
Boolean,
Date,
DateTime,
Decimal,
Float,
Int,
List,
NonNull,
DateTime,
Date,
String,
Time,
)
from ..converter import convert_form_field
def assert_conversion(django_field, graphene_field, *args):
field = django_field(*args, help_text="Custom Help Text")
def assert_conversion(django_field, graphene_field, *args, **kwargs):
# Arrange
help_text = kwargs.setdefault("help_text", "Custom Help Text")
field = django_field(*args, **kwargs)
# Act
graphene_type = convert_form_field(field)
# Assert
assert isinstance(graphene_type, graphene_field)
field = graphene_type.Field()
assert field.description == "Custom Help Text"
assert field.description == help_text
return field
@ -59,7 +63,12 @@ def test_should_slug_convert_string():
def test_should_url_convert_string():
assert_conversion(forms.URLField, String)
kwargs = {}
if DJANGO_VERSION >= (5, 0):
# silence RemovedInDjango60Warning
kwargs["assume_scheme"] = "https"
assert_conversion(forms.URLField, String, **kwargs)
def test_should_choice_convert_string():
@ -75,8 +84,7 @@ def test_should_regex_convert_string():
def test_should_uuid_convert_string():
if hasattr(forms, "UUIDField"):
assert_conversion(forms.UUIDField, UUID)
assert_conversion(forms.UUIDField, UUID)
def test_should_integer_convert_int():
@ -97,8 +105,8 @@ def test_should_float_convert_float():
assert_conversion(forms.FloatField, Float)
def test_should_decimal_convert_float():
assert_conversion(forms.DecimalField, Float)
def test_should_decimal_convert_decimal():
assert_conversion(forms.DecimalField, Decimal)
def test_should_multiple_choice_convert_list():

View 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

View File

@ -1,7 +1,6 @@
import pytest
from django import forms
from django.core.exceptions import ValidationError
from py.test import raises
from pytest import raises
from graphene import Field, ObjectType, Schema, String
from graphene_django import DjangoObjectType
@ -65,11 +64,11 @@ def test_mutation_error_camelcased(graphene_settings):
class Meta:
form_class = ExtraPetForm
result = PetMutation.mutate_and_get_payload(None, None)
assert {f.field for f in result.errors} == {"name", "age", "test_field"}
graphene_settings.CAMELCASE_ERRORS = True
result = PetMutation.mutate_and_get_payload(None, None)
assert {f.field for f in result.errors} == {"name", "age", "testField"}
graphene_settings.CAMELCASE_ERRORS = False
result = PetMutation.mutate_and_get_payload(None, None)
assert {f.field for f in result.errors} == {"name", "age", "test_field"}
class MockQuery(ObjectType):
@ -280,7 +279,7 @@ def test_model_form_mutation_mutate_invalid_form():
result = PetMutation.mutate_and_get_payload(None, None)
# A pet was not created
Pet.objects.count() == 0
assert Pet.objects.count() == 0
fields_w_error = [e.field for e in result.errors]
assert len(result.errors) == 2

Some files were not shown because too many files have changed in this diff Show More