diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..5dd418e --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,25 @@ +name: 🚀 Deploy to PyPI + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.8 + uses: actions/setup-python@v1 + with: + python-version: 3.8 + - name: Build wheel and source tarball + run: | + python setup.py sdist bdist_wheel + - name: Publish a Python distribution to PyPI + uses: pypa/gh-action-pypi-publish@v1.1.0 + with: + user: __token__ + password: ${{ secrets.pypi_password }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..20cf7fb --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,22 @@ +name: Lint + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v1 + - name: Set up Python 3.8 + uses: actions/setup-python@v1 + with: + python-version: 3.8 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox + - name: Run lint 💅 + run: tox + env: + TOXENV: flake8 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..270b24e --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,28 @@ +name: Tests + +on: [push, pull_request] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + max-parallel: 4 + matrix: + django: ["2.2", "3.0"] + python-version: ["3.6", "3.7", "3.8"] + + steps: + - uses: actions/checkout@v1 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v1 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - name: Test with tox + run: tox + env: + DJANGO: ${{ matrix.django }} + TOXENV: ${{ matrix.toxenv }} diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index f1ca201..0000000 --- a/.travis.yml +++ /dev/null @@ -1,68 +0,0 @@ -language: python -cache: pip -dist: xenial - -install: - - pip install tox tox-travis - -script: - - tox - -after_success: - - pip install coveralls - - coveralls - -stages: - - test - - name: deploy - if: tag IS present - -jobs: - fast_finish: true - - allow_failures: - - env: DJANGO=master - - include: - - python: 3.6 - env: DJANGO=1.11 - - python: 3.6 - env: DJANGO=2.2 - - python: 3.6 - env: DJANGO=3.0 - - python: 3.6 - env: DJANGO=master - - - python: 3.7 - env: DJANGO=1.11 - - python: 3.7 - env: DJANGO=2.2 - - python: 3.7 - env: DJANGO=3.0 - - python: 3.7 - env: DJANGO=master - - - python: 3.8 - env: DJANGO=1.11 - - python: 3.8 - env: DJANGO=2.2 - - python: 3.8 - env: DJANGO=3.0 - - python: 3.8 - env: DJANGO=master - - - python: 3.8 - env: TOXENV=black,flake8 - - - stage: deploy - script: skip - python: 3.8 - after_success: true - deploy: - provider: pypi - user: syrusakbary - on: - tags: true - password: - secure: kymIFCEPUbkgRqe2NAXkWfxMmGRfWvWBOP6LIXdVdkOOkm91fU7bndPGrAjos+/7gN0Org609ZmHSlVXNMJUWcsL2or/x5LcADJ4cZDe+79qynuoRb9xs1Ri4O4SBAuVMZxuVJvs8oUzT2R11ql5vASSMtXgbX+ZDGpmPRVZStkCuXgOc4LBhbPKyl3OFy7UQFPgAEmy3Yjh4ZSKzlXheK+S6mmr60+DCIjpaA0BWPxYK9FUE0qm7JJbHLUbwsUP/QMp5MmGjwFisXCNsIe686B7QKRaiOw62eJc2R7He8AuEC8T9OM4kRwDlecSn8mMpkoSB7QWtlJ+6XdLrJFPNvtrOfgfzS9/96Qrw9WlOslk68hMlhJeRb0s2YUD8tiV3UUkvbL1mfFoS4SI9U+rojS55KhUEJWHg1w7DjoOPoZmaIL2ChRupmvrFYNAGae1cxwG3Urh+t3wYlN3gpKsRDe5GOT7Wm2tr0ad3McCpDGUwSChX59BAJXe/MoLxkKScTrMyR8yMxHOF0b4zpVn5l7xB/o2Ik4zavx5q/0rGBMK2D+5d+gpQogKShoquTPsZUwO7sB5hYeH2hqGqpeGzZtb76E2zZYd18pJ0FsBudm5+KWjYdZ+vbtGrLxdTXJ1EEtzVXm0lscykTpqUucbXSa51dhStJvW2xEEz6p3rHo= - distributions: "sdist bdist_wheel" diff --git a/README.md b/README.md index 8605065..2490209 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ A [Django](https://www.djangoproject.com/) integration for [Graphene](http://gra For installing graphene, just run this command in your shell ```bash -pip install "graphene-django>=2.0" +pip install "graphene-django>=3" ``` ### Settings diff --git a/README.rst b/README.rst index 44feaee..4ac7dda 100644 --- a/README.rst +++ b/README.rst @@ -23,7 +23,7 @@ For installing graphene, just run this command in your shell .. code:: bash - pip install "graphene-django>=2.0" + pip install "graphene-django>=3" Settings ~~~~~~~~ diff --git a/docs/authorization.rst b/docs/authorization.rst index 7e09c37..8ef05b4 100644 --- a/docs/authorization.rst +++ b/docs/authorization.rst @@ -166,16 +166,7 @@ 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: - -.. code:: python - - urlpatterns = [ - # some other urls - url(r'^graphql$', PrivateGraphQLView.as_view(graphiql=True, schema=schema)), - ] - -For Django 2.0 and above: +For Django 2.2 and above: .. code:: python diff --git a/docs/filtering.rst b/docs/filtering.rst index cab61ec..a511c64 100644 --- a/docs/filtering.rst +++ b/docs/filtering.rst @@ -2,8 +2,8 @@ Filtering ========= Graphene integrates with -`django-filter `__ to provide filtering of results. See the `usage -documentation `__ +`django-filter `__ to provide filtering of results. +See the `usage documentation `__ for details on the format for ``filter_fields``. This filtering is automatically available when implementing a ``relay.Node``. @@ -123,6 +123,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 @@ -135,6 +144,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: @@ -162,6 +187,7 @@ 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 `__ 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 diff --git a/docs/installation.rst b/docs/installation.rst index 048a994..573032e 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -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 diff --git a/graphene_django/conftest.py b/graphene_django/conftest.py new file mode 100644 index 0000000..509a84c --- /dev/null +++ b/graphene_django/conftest.py @@ -0,0 +1,18 @@ +import pytest + +from graphene_django.settings import graphene_settings as gsettings + +from .registry import reset_global_registry + + +@pytest.fixture(autouse=True) +def reset_registry_fixture(db): + yield None + reset_global_registry() + + +@pytest.fixture() +def graphene_settings(): + settings = dict(gsettings.__dict__) + yield gsettings + gsettings.__dict__ = settings diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 36116ed..187874a 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -3,11 +3,14 @@ from functools import singledispatch 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 graphene import ( ID, + UUID, Boolean, + Date, + DateTime, Dynamic, Enum, Field, @@ -16,25 +19,23 @@ from graphene import ( List, NonNull, String, - UUID, - DateTime, - Date, Time, ) from graphene.types.json import JSONString from graphene.utils.str_converters import to_camel_case, to_const -from graphql import assert_valid_name +from graphql import GraphQLError, assert_valid_name +from graphql.pyutils import register_description -from .settings import graphene_settings from .compat import ArrayField, HStoreField, JSONField, RangeField -from .fields import DjangoListField, DjangoConnectionField +from .fields import DjangoConnectionField, DjangoListField +from .settings import graphene_settings def convert_choice_name(name): name = to_const(force_str(name)) try: assert_valid_name(name) - except AssertionError: + except GraphQLError: name = "A_%s" % name return name @@ -52,7 +53,9 @@ def get_choices(choices): 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,7 +67,7 @@ def convert_choices_to_named_enum_with_descriptions(name, choices): class EnumWithDescriptionsType(object): @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) @@ -276,3 +279,8 @@ def convert_postgres_range_to_string(field, registry=None): if not isinstance(inner_type, (List, NonNull)): inner_type = type(inner_type) return List(inner_type, description=field.help_text, 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) diff --git a/graphene_django/debug/middleware.py b/graphene_django/debug/middleware.py index 0fe3fe3..8621b55 100644 --- a/graphene_django/debug/middleware.py +++ b/graphene_django/debug/middleware.py @@ -17,7 +17,7 @@ class DjangoDebugContext(object): if not self.debug_promise: self.debug_promise = Promise.all(self.promises) self.promises = [] - return self.debug_promise.then(self.on_resolve_all_promises) + return self.debug_promise.then(self.on_resolve_all_promises).get() def on_resolve_all_promises(self, values): if self.promises: diff --git a/graphene_django/debug/tests/test_query.py b/graphene_django/debug/tests/test_query.py index db8f275..7255ec6 100644 --- a/graphene_django/debug/tests/test_query.py +++ b/graphene_django/debug/tests/test_query.py @@ -1,5 +1,3 @@ -import pytest - import graphene from graphene.relay import Node from graphene_django import DjangoConnectionField, DjangoObjectType @@ -13,11 +11,6 @@ class context(object): pass -# from examples.starwars_django.models import Character - -pytestmark = pytest.mark.django_db - - def test_should_query_field(): r1 = Reporter(last_name="ABA") r1.save() @@ -75,7 +68,7 @@ def test_should_query_nested_field(): 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() @@ -89,7 +82,7 @@ def test_should_query_nested_field(): pets { edges { node { lastName } } } } } } } - __debug { + _debug { sql { rawSql } @@ -117,12 +110,12 @@ def test_should_query_nested_field(): ) 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"] diff --git a/graphene_django/fields.py b/graphene_django/fields.py index f0a3828..418a14b 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -1,11 +1,12 @@ from functools import partial from django.db.models.query import QuerySet -from graphql_relay.connection.arrayconnection import connection_from_list_slice +from graphql_relay.connection.arrayconnection import connection_from_array_slice from promise import Promise from graphene import 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 @@ -122,15 +123,15 @@ class DjangoConnectionField(ConnectionField): _len = iterable.count() else: _len = len(iterable) - connection = connection_from_list_slice( + connection = connection_from_array_slice( iterable, args, slice_start=0, - list_length=_len, - list_slice_length=_len, - connection_type=connection, + array_length=_len, + array_slice_length=_len, + connection_type=partial(connection_adapter, connection), edge_type=connection.Edge, - pageinfo_type=PageInfo, + page_info_type=page_info_adapter, ) connection.iterable = iterable connection.length = _len diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index a0f7d96..59cc30b 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -35,9 +35,6 @@ else: ) ) -pytestmark.append(pytest.mark.django_db) - - if DJANGO_FILTER_INSTALLED: class ArticleNode(DjangoObjectType): @@ -809,38 +806,56 @@ def test_integer_field_filter_type(): assert str(schema) == dedent( """\ - schema { - query: Query - } - - interface Node { - id: ID! - } - - type PageInfo { - hasNextPage: Boolean! - hasPreviousPage: Boolean! - startCursor: String - endCursor: String - } - - type PetType implements Node { - age: Int! - id: ID! + type Query { + pets(before: String = null, after: String = null, first: Int = null, last: Int = null, age: Int = null): PetTypeConnection } 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 Query { - pets(before: String, after: String, first: Int, last: Int, age: Int): PetTypeConnection + + type PetType implements Node { + \"""\""" + age: Int! + + \"""The ID of the object\""" + id: ID! + } + + \"""An object with an ID\""" + interface Node { + \"""The ID of the object\""" + id: ID! } """ ) @@ -861,40 +876,58 @@ def test_other_filter_types(): assert str(schema) == dedent( """\ - schema { - query: Query - } - - interface Node { - id: ID! - } - - type PageInfo { - hasNextPage: Boolean! - hasPreviousPage: Boolean! - startCursor: String - endCursor: String - } - - type PetType implements Node { - age: Int! - id: ID! + type Query { + pets(before: String = null, after: String = null, first: Int = null, last: Int = null, age: Int = null, age_Isnull: Boolean = null, age_Lt: Int = null): PetTypeConnection } 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 Query { - pets(before: String, after: String, first: Int, last: Int, age: Int, age_Isnull: Boolean, age_Lt: Int): PetTypeConnection + type PetType implements Node { + \"""\""" + age: Int! + + \"""The ID of the object\""" + id: ID! } - """ + + \"""An object with an ID\""" + interface Node { + \"""The ID of the object\""" + id: ID! + } + """ ) diff --git a/graphene_django/forms/converter.py b/graphene_django/forms/converter.py index 7b154b4..077e984 100644 --- a/graphene_django/forms/converter.py +++ b/graphene_django/forms/converter.py @@ -55,9 +55,14 @@ def convert_form_field_to_float(field): return Float(description=field.help_text, required=field.required) +@convert_form_field.register(forms.MultipleChoiceField) +def convert_form_field_to_string_list(field): + return List(String, 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): +def convert_form_field_to_id_list(field): return List(ID, required=field.required) diff --git a/graphene_django/forms/tests/test_converter.py b/graphene_django/forms/tests/test_converter.py index 955b952..29a4419 100644 --- a/graphene_django/forms/tests/test_converter.py +++ b/graphene_django/forms/tests/test_converter.py @@ -66,6 +66,10 @@ def test_should_choice_convert_string(): assert_conversion(forms.ChoiceField, String) +def test_should_multiple_choice_convert_list(): + assert_conversion(forms.MultipleChoiceField, List) + + def test_should_base_field_convert_string(): assert_conversion(forms.Field, String) diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py index 093f398..a455a0a 100644 --- a/graphene_django/forms/tests/test_mutation.py +++ b/graphene_django/forms/tests/test_mutation.py @@ -1,16 +1,25 @@ +import pytest from django import forms -from django.test import TestCase from django.core.exceptions import ValidationError from py.test import raises -from graphene import ObjectType, Schema, String, Field +from graphene import Field, ObjectType, Schema, String from graphene_django import DjangoObjectType -from graphene_django.tests.models import Film, Pet +from graphene_django.tests.models import Pet -from ...settings import graphene_settings from ..mutation import DjangoFormMutation, DjangoModelFormMutation +@pytest.fixture() +def pet_type(): + class PetType(DjangoObjectType): + class Meta: + model = Pet + fields = "__all__" + + return PetType + + class MyForm(forms.Form): text = forms.CharField() @@ -36,18 +45,6 @@ class PetForm(forms.ModelForm): return age -class PetType(DjangoObjectType): - class Meta: - model = Pet - fields = "__all__" - - -class FilmType(DjangoObjectType): - class Meta: - model = Film - fields = "__all__" - - def test_needs_form_class(): with raises(Exception) as exc: @@ -73,7 +70,7 @@ def test_has_input_fields(): assert "text" in MyMutation.Input._meta.fields -def test_mutation_error_camelcased(): +def test_mutation_error_camelcased(pet_type, graphene_settings): class ExtraPetForm(PetForm): test_field = forms.CharField(required=True) @@ -86,234 +83,237 @@ def test_mutation_error_camelcased(): 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 class MockQuery(ObjectType): a = String() -class FormMutationTests(TestCase): - def test_form_invalid_form(self): - class MyMutation(DjangoFormMutation): - class Meta: - form_class = MyForm +def test_form_invalid_form(): + class MyMutation(DjangoFormMutation): + class Meta: + form_class = MyForm - class Mutation(ObjectType): - my_mutation = MyMutation.Field() + class Mutation(ObjectType): + my_mutation = MyMutation.Field() - schema = Schema(query=MockQuery, mutation=Mutation) + schema = Schema(query=MockQuery, mutation=Mutation) - result = schema.execute( - """ mutation MyMutation { - myMutation(input: { text: "INVALID_INPUT" }) { - errors { - field - messages - } - text + result = schema.execute( + """ mutation MyMutation { + myMutation(input: { text: "INVALID_INPUT" }) { + errors { + field + messages + } + text + } + } + """ + ) + + assert result.errors is None + assert result.data["myMutation"]["errors"] == [ + {"field": "text", "messages": ["Invalid input"]} + ] + + +def test_form_valid_input(): + class MyMutation(DjangoFormMutation): + class Meta: + form_class = MyForm + + class Mutation(ObjectType): + my_mutation = MyMutation.Field() + + schema = Schema(query=MockQuery, mutation=Mutation) + + result = schema.execute( + """ mutation MyMutation { + myMutation(input: { text: "VALID_INPUT" }) { + errors { + field + messages + } + text + } + } + """ + ) + + assert result.errors is None + assert result.data["myMutation"]["errors"] == [] + assert result.data["myMutation"]["text"] == "VALID_INPUT" + + +def test_default_meta_fields(pet_type): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm + + assert PetMutation._meta.model is Pet + assert PetMutation._meta.return_field_name == "pet" + assert "pet" in PetMutation._meta.fields + + +def test_default_input_meta_fields(pet_type): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm + + assert PetMutation._meta.model is Pet + assert PetMutation._meta.return_field_name == "pet" + assert "name" in PetMutation.Input._meta.fields + assert "client_mutation_id" in PetMutation.Input._meta.fields + assert "id" in PetMutation.Input._meta.fields + + +def test_exclude_fields_input_meta_fields(pet_type): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm + exclude_fields = ["id"] + + assert PetMutation._meta.model is Pet + assert PetMutation._meta.return_field_name == "pet" + assert "name" in PetMutation.Input._meta.fields + assert "age" in PetMutation.Input._meta.fields + assert "client_mutation_id" in PetMutation.Input._meta.fields + assert "id" not in PetMutation.Input._meta.fields + + +def test_custom_return_field_name(pet_type): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm + model = Pet + return_field_name = "animal" + + assert PetMutation._meta.model is Pet + assert PetMutation._meta.return_field_name == "animal" + assert "animal" in PetMutation._meta.fields + + +def test_model_form_mutation_mutate_existing(pet_type): + class PetMutation(DjangoModelFormMutation): + pet = Field(pet_type) + + class Meta: + form_class = PetForm + + class Mutation(ObjectType): + pet_mutation = PetMutation.Field() + + schema = Schema(query=MockQuery, mutation=Mutation) + + pet = Pet.objects.create(name="Axel", age=10) + + result = schema.execute( + """ mutation PetMutation($pk: ID!) { + petMutation(input: { id: $pk, name: "Mia", age: 10 }) { + pet { + name + age } } - """ - ) + } + """, + variable_values={"pk": pet.pk}, + ) - self.assertIs(result.errors, None) - self.assertEqual( - result.data["myMutation"]["errors"], - [{"field": "text", "messages": ["Invalid input"]}], - ) + assert result.errors is None + assert result.data["petMutation"]["pet"] == {"name": "Mia", "age": 10} - def test_form_valid_input(self): - class MyMutation(DjangoFormMutation): - class Meta: - form_class = MyForm + assert Pet.objects.count() == 1 + pet.refresh_from_db() + assert pet.name == "Mia" - class Mutation(ObjectType): - my_mutation = MyMutation.Field() - schema = Schema(query=MockQuery, mutation=Mutation) +def test_model_form_mutation_creates_new(pet_type): + class PetMutation(DjangoModelFormMutation): + pet = Field(pet_type) - result = schema.execute( - """ mutation MyMutation { - myMutation(input: { text: "VALID_INPUT" }) { - errors { - field - messages - } - text + class Meta: + form_class = PetForm + + class Mutation(ObjectType): + pet_mutation = PetMutation.Field() + + schema = Schema(query=MockQuery, mutation=Mutation) + + result = schema.execute( + """ mutation PetMutation { + petMutation(input: { name: "Mia", age: 10 }) { + pet { + name + age + } + errors { + field + messages } } - """ - ) + } + """ + ) + assert result.errors is None + assert result.data["petMutation"]["pet"] == {"name": "Mia", "age": 10} - self.assertIs(result.errors, None) - self.assertEqual(result.data["myMutation"]["errors"], []) - self.assertEqual(result.data["myMutation"]["text"], "VALID_INPUT") + assert Pet.objects.count() == 1 + pet = Pet.objects.get() + assert pet.name == "Mia" + assert pet.age == 10 -class ModelFormMutationTests(TestCase): - def test_default_meta_fields(self): - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm +def test_model_form_mutation_invalid_input(pet_type): + class PetMutation(DjangoModelFormMutation): + pet = Field(pet_type) - self.assertEqual(PetMutation._meta.model, Pet) - self.assertEqual(PetMutation._meta.return_field_name, "pet") - self.assertIn("pet", PetMutation._meta.fields) + class Meta: + form_class = PetForm - def test_default_input_meta_fields(self): - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm + class Mutation(ObjectType): + pet_mutation = PetMutation.Field() - self.assertEqual(PetMutation._meta.model, Pet) - self.assertEqual(PetMutation._meta.return_field_name, "pet") - self.assertIn("name", PetMutation.Input._meta.fields) - self.assertIn("client_mutation_id", PetMutation.Input._meta.fields) - self.assertIn("id", PetMutation.Input._meta.fields) + schema = Schema(query=MockQuery, mutation=Mutation) - def test_exclude_fields_input_meta_fields(self): - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm - exclude_fields = ["id"] - - self.assertEqual(PetMutation._meta.model, Pet) - self.assertEqual(PetMutation._meta.return_field_name, "pet") - self.assertIn("name", PetMutation.Input._meta.fields) - self.assertIn("age", PetMutation.Input._meta.fields) - self.assertIn("client_mutation_id", PetMutation.Input._meta.fields) - self.assertNotIn("id", PetMutation.Input._meta.fields) - - def test_custom_return_field_name(self): - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm - model = Pet - return_field_name = "animal" - - self.assertEqual(PetMutation._meta.model, Pet) - self.assertEqual(PetMutation._meta.return_field_name, "animal") - self.assertIn("animal", PetMutation._meta.fields) - - def test_model_form_mutation_mutate_existing(self): - class PetMutation(DjangoModelFormMutation): - pet = Field(PetType) - - class Meta: - form_class = PetForm - - class Mutation(ObjectType): - pet_mutation = PetMutation.Field() - - schema = Schema(query=MockQuery, mutation=Mutation) - - pet = Pet.objects.create(name="Axel", age=10) - - result = schema.execute( - """ mutation PetMutation($pk: ID!) { - petMutation(input: { id: $pk, name: "Mia", age: 10 }) { - pet { - name - age - } + result = schema.execute( + """ mutation PetMutation { + petMutation(input: { name: "Mia", age: 99 }) { + pet { + name + age + } + errors { + field + messages } } - """, - variable_values={"pk": pet.pk}, - ) + } + """ + ) + assert result.errors is None + assert result.data["petMutation"]["pet"] is None + assert result.data["petMutation"]["errors"] == [ + {"field": "age", "messages": ["Too old"]} + ] - self.assertIs(result.errors, None) - self.assertEqual(result.data["petMutation"]["pet"], {"name": "Mia", "age": 10}) + assert Pet.objects.count() == 0 - self.assertEqual(Pet.objects.count(), 1) - pet.refresh_from_db() - self.assertEqual(pet.name, "Mia") - def test_model_form_mutation_creates_new(self): - class PetMutation(DjangoModelFormMutation): - pet = Field(PetType) +def test_model_form_mutation_mutate_invalid_form(pet_type): + class PetMutation(DjangoModelFormMutation): + class Meta: + form_class = PetForm - class Meta: - form_class = PetForm + result = PetMutation.mutate_and_get_payload(None, None) - class Mutation(ObjectType): - pet_mutation = PetMutation.Field() + # A pet was not created + Pet.objects.count() == 0 - schema = Schema(query=MockQuery, mutation=Mutation) - - result = schema.execute( - """ mutation PetMutation { - petMutation(input: { name: "Mia", age: 10 }) { - pet { - name - age - } - errors { - field - messages - } - } - } - """ - ) - self.assertIs(result.errors, None) - self.assertEqual(result.data["petMutation"]["pet"], {"name": "Mia", "age": 10}) - - self.assertEqual(Pet.objects.count(), 1) - pet = Pet.objects.get() - self.assertEqual(pet.name, "Mia") - self.assertEqual(pet.age, 10) - - def test_model_form_mutation_invalid_input(self): - class PetMutation(DjangoModelFormMutation): - pet = Field(PetType) - - class Meta: - form_class = PetForm - - class Mutation(ObjectType): - pet_mutation = PetMutation.Field() - - schema = Schema(query=MockQuery, mutation=Mutation) - - result = schema.execute( - """ mutation PetMutation { - petMutation(input: { name: "Mia", age: 99 }) { - pet { - name - age - } - errors { - field - messages - } - } - } - """ - ) - self.assertIs(result.errors, None) - self.assertEqual(result.data["petMutation"]["pet"], None) - self.assertEqual( - result.data["petMutation"]["errors"], - [{"field": "age", "messages": ["Too old"],}], - ) - - self.assertEqual(Pet.objects.count(), 0) - - def test_model_form_mutation_mutate_invalid_form(self): - class PetMutation(DjangoModelFormMutation): - class Meta: - form_class = PetForm - - result = PetMutation.mutate_and_get_payload(None, None) - - # A pet was not created - self.assertEqual(Pet.objects.count(), 0) - - fields_w_error = [e.field for e in result.errors] - self.assertEqual(len(result.errors), 2) - self.assertIn("name", fields_w_error) - self.assertEqual(result.errors[0].messages, ["This field is required."]) - self.assertIn("age", fields_w_error) - self.assertEqual(result.errors[1].messages, ["This field is required."]) + fields_w_error = [e.field for e in result.errors] + assert len(result.errors) == 2 + assert result.errors[0].messages == ["This field is required."] + assert result.errors[1].messages == ["This field is required."] + assert "age" in fields_w_error + assert "name" in fields_w_error diff --git a/graphene_django/management/commands/graphql_schema.py b/graphene_django/management/commands/graphql_schema.py index 751a385..9cf55ca 100644 --- a/graphene_django/management/commands/graphql_schema.py +++ b/graphene_django/management/commands/graphql_schema.py @@ -48,6 +48,7 @@ class CommandArguments(BaseCommand): class Command(CommandArguments): help = "Dump Graphene schema as a JSON or GraphQL file" can_import_settings = True + requires_system_checks = False def save_json_file(self, out, schema_dict, indent): with open(out, "w") as outfile: @@ -55,7 +56,7 @@ class Command(CommandArguments): def save_graphql_file(self, out, schema): with open(out, "w") as outfile: - outfile.write(print_schema(schema)) + outfile.write(print_schema(schema.graphql_schema)) def get_schema(self, schema, out, indent): schema_dict = {"data": schema.introspect()} diff --git a/graphene_django/rest_framework/tests/test_multiple_model_serializers.py b/graphene_django/rest_framework/tests/test_multiple_model_serializers.py index c1f4626..1676b62 100644 --- a/graphene_django/rest_framework/tests/test_multiple_model_serializers.py +++ b/graphene_django/rest_framework/tests/test_multiple_model_serializers.py @@ -1,14 +1,11 @@ -import graphene -import pytest from django.db import models -from graphene import Schema from rest_framework import serializers +import graphene +from graphene import Schema from graphene_django import DjangoObjectType from graphene_django.rest_framework.mutation import SerializerMutation -pytestmark = pytest.mark.django_db - class MyFakeChildModel(models.Model): name = models.CharField(max_length=50) diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py index 5bf3bc1..1b31e36 100644 --- a/graphene_django/rest_framework/tests/test_mutation.py +++ b/graphene_django/rest_framework/tests/test_mutation.py @@ -1,14 +1,13 @@ import datetime -from py.test import mark, raises +from py.test import raises from rest_framework import serializers from graphene import Field, ResolveInfo from graphene.types.inputobjecttype import InputObjectType -from ...settings import graphene_settings from ...types import DjangoObjectType -from ..models import MyFakeModel, MyFakeModelWithPassword, MyFakeModelWithDate +from ..models import MyFakeModel, MyFakeModelWithDate, MyFakeModelWithPassword from ..mutation import SerializerMutation @@ -18,12 +17,14 @@ def mock_info(): None, None, None, + path=None, schema=None, fragments=None, root_value=None, operation=None, variable_values=None, context=None, + is_awaitable=None, ) @@ -99,7 +100,6 @@ def test_exclude_fields(): assert "created" not in MyMutation.Input._meta.fields -@mark.django_db def test_write_only_field(): class WriteOnlyFieldModelSerializer(serializers.ModelSerializer): password = serializers.CharField(write_only=True) @@ -122,7 +122,6 @@ def test_write_only_field(): ), "'password' is write_only field and shouldn't be visible" -@mark.django_db def test_write_only_field_using_extra_kwargs(): class WriteOnlyFieldModelSerializer(serializers.ModelSerializer): class Meta: @@ -144,7 +143,6 @@ def test_write_only_field_using_extra_kwargs(): ), "'password' is write_only field and shouldn't be visible" -@mark.django_db def test_read_only_fields(): class ReadOnlyFieldModelSerializer(serializers.ModelSerializer): cool_name = serializers.CharField(read_only=True) @@ -194,7 +192,6 @@ def test_mutate_and_get_payload_success(): assert result.errors is None -@mark.django_db def test_model_add_mutate_and_get_payload_success(): result = MyModelMutation.mutate_and_get_payload( None, mock_info(), **{"cool_name": "Narf"} @@ -204,7 +201,6 @@ def test_model_add_mutate_and_get_payload_success(): assert isinstance(result.created, datetime.datetime) -@mark.django_db def test_model_update_mutate_and_get_payload_success(): instance = MyFakeModel.objects.create(cool_name="Narf") result = MyModelMutation.mutate_and_get_payload( @@ -214,7 +210,6 @@ def test_model_update_mutate_and_get_payload_success(): assert result.cool_name == "New Narf" -@mark.django_db def test_model_partial_update_mutate_and_get_payload_success(): instance = MyFakeModel.objects.create(cool_name="Narf") result = MyModelMutation.mutate_and_get_payload( @@ -224,7 +219,6 @@ def test_model_partial_update_mutate_and_get_payload_success(): assert result.cool_name == "Narf" -@mark.django_db def test_model_invalid_update_mutate_and_get_payload_success(): class InvalidModelMutation(SerializerMutation): class Meta: @@ -239,7 +233,6 @@ def test_model_invalid_update_mutate_and_get_payload_success(): assert '"id" required' in str(exc.value) -@mark.django_db def test_perform_mutate_success(): class MyMethodMutation(SerializerMutation): class Meta: @@ -272,11 +265,10 @@ def test_model_mutate_and_get_payload_error(): assert len(result.errors) > 0 -def test_mutation_error_camelcased(): +def test_mutation_error_camelcased(graphene_settings): graphene_settings.CAMELCASE_ERRORS = True result = MyModelMutation.mutate_and_get_payload(None, mock_info(), **{}) assert result.errors[0].field == "coolName" - graphene_settings.CAMELCASE_ERRORS = False def test_invalid_serializer_operations(): diff --git a/graphene_django/tests/test_command.py b/graphene_django/tests/test_command.py index 6dfe330..70116b8 100644 --- a/graphene_django/tests/test_command.py +++ b/graphene_django/tests/test_command.py @@ -8,7 +8,7 @@ from graphene import ObjectType, Schema, String @patch("graphene_django.management.commands.graphql_schema.Command.save_json_file") -def test_generate_json_file_on_call_graphql_schema(savefile_mock, settings): +def test_generate_json_file_on_call_graphql_schema(savefile_mock): out = StringIO() management.call_command("graphql_schema", schema="", stdout=out) assert "Successfully dumped GraphQL schema to schema.json" in out.getvalue() @@ -51,10 +51,6 @@ def test_generate_graphql_file_on_call_graphql_schema(): schema_output = handle.write.call_args[0][0] assert schema_output == dedent( """\ - schema { - query: Query - } - type Query { hi: String } diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index f1d1b9b..0225e66 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -1,16 +1,17 @@ -import pytest from collections import namedtuple + +import pytest from django.db import models from django.utils.translation import gettext_lazy as _ -from graphene import NonNull from py.test import raises import graphene +from graphene import NonNull from graphene.relay import ConnectionField, Node -from graphene.types.datetime import DateTime, Date, Time +from graphene.types.datetime import Date, DateTime, Time from graphene.types.json import JSONString -from ..compat import JSONField, ArrayField, HStoreField, RangeField, MissingType +from ..compat import ArrayField, HStoreField, JSONField, MissingType, RangeField from ..converter import ( convert_django_field, convert_django_field_with_choices, @@ -18,10 +19,8 @@ from ..converter import ( ) from ..registry import Registry from ..types import DjangoObjectType -from ..settings import graphene_settings from .models import Article, Film, FilmDetails, Reporter - # from graphene.core.types.custom_scalars import DateTime, Time, JSONString @@ -333,7 +332,7 @@ def test_should_postgres_range_convert_list(): assert field.type.of_type.of_type == graphene.Int -def test_generate_enum_name(): +def test_generate_enum_name(graphene_settings): MockDjangoModelMeta = namedtuple("DjangoMeta", ["app_label", "object_name"]) graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = True @@ -351,5 +350,3 @@ def test_generate_enum_name(): generate_enum_name(model_meta, field) == "SomeLongAppNameSomeObjectFizzBuzzChoices" ) - - graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = False diff --git a/graphene_django/tests/test_fields.py b/graphene_django/tests/test_fields.py index 8ea1901..67b3a35 100644 --- a/graphene_django/tests/test_fields.py +++ b/graphene_django/tests/test_fields.py @@ -10,7 +10,6 @@ from .models import Article as ArticleModel from .models import Reporter as ReporterModel -@pytest.mark.django_db class TestDjangoListField: def test_only_django_object_types(self): class TestType(ObjectType): diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 95db2d1..75053db 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1,25 +1,20 @@ -import base64 import datetime import pytest from django.db import models +from django.db.models import Q from django.utils.functional import SimpleLazyObject +from graphql_relay import to_global_id from py.test import raises -from django.db.models import Q - -from graphql_relay import to_global_id import graphene from graphene.relay import Node -from ..utils import DJANGO_FILTER_INSTALLED -from ..compat import MissingType, JSONField +from ..compat import JSONField, MissingType from ..fields import DjangoConnectionField from ..types import DjangoObjectType -from ..settings import graphene_settings -from .models import Article, CNNReporter, Reporter, Film, FilmDetails - -pytestmark = pytest.mark.django_db +from ..utils import DJANGO_FILTER_INSTALLED +from .models import Article, CNNReporter, Film, FilmDetails, Reporter def test_should_query_only_fields(): @@ -147,9 +142,6 @@ def test_should_query_postgres_fields(): def test_should_node(): - # reset_global_registry() - # Node._meta.registry = get_global_registry() - class ReporterNode(DjangoObjectType): class Meta: model = Reporter @@ -588,7 +580,7 @@ def test_should_query_node_multiple_filtering(): assert result.data == expected -def test_should_enforce_first_or_last(): +def test_should_enforce_first_or_last(graphene_settings): graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST = True class ReporterType(DjangoObjectType): @@ -620,14 +612,14 @@ def test_should_enforce_first_or_last(): result = schema.execute(query) assert len(result.errors) == 1 - assert str(result.errors[0]) == ( + assert str(result.errors[0]).startswith( "You must provide a `first` or `last` value to properly " - "paginate the `allReporters` connection." + "paginate the `allReporters` connection.\n" ) assert result.data == expected -def test_should_error_if_first_is_greater_than_max(): +def test_should_error_if_first_is_greater_than_max(graphene_settings): graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 100 class ReporterType(DjangoObjectType): @@ -661,16 +653,14 @@ def test_should_error_if_first_is_greater_than_max(): result = schema.execute(query) assert len(result.errors) == 1 - assert str(result.errors[0]) == ( + assert str(result.errors[0]).startswith( "Requesting 101 records on the `allReporters` connection " - "exceeds the `first` limit of 100 records." + "exceeds the `first` limit of 100 records.\n" ) assert result.data == expected - graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST = False - -def test_should_error_if_last_is_greater_than_max(): +def test_should_error_if_last_is_greater_than_max(graphene_settings): graphene_settings.RELAY_CONNECTION_MAX_LIMIT = 100 class ReporterType(DjangoObjectType): @@ -704,14 +694,12 @@ def test_should_error_if_last_is_greater_than_max(): result = schema.execute(query) assert len(result.errors) == 1 - assert str(result.errors[0]) == ( + assert str(result.errors[0]).startswith( "Requesting 101 records on the `allReporters` connection " - "exceeds the `last` limit of 100 records." + "exceeds the `last` limit of 100 records.\n" ) assert result.data == expected - graphene_settings.RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST = False - def test_should_query_promise_connectionfields(): from promise import Promise @@ -725,7 +713,7 @@ def test_should_query_promise_connectionfields(): all_reporters = DjangoConnectionField(ReporterType) def resolve_all_reporters(self, info, **args): - return Promise.resolve([Reporter(id=1)]) + return Promise.resolve([Reporter(id=1)]).get() schema = graphene.Schema(query=Query) query = """ @@ -854,7 +842,7 @@ def test_should_query_dataloader_fields(): articles = DjangoConnectionField(ArticleType) def resolve_articles(self, info, **args): - return article_loader.load(self.id) + return article_loader.load(self.id).get() class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) @@ -1087,7 +1075,7 @@ def test_should_preserve_prefetch_related(django_assert_num_queries): class Query(graphene.ObjectType): films = DjangoConnectionField(FilmType) - def resolve_films(root, info): + def resolve_films(root, info, **kwargs): qs = Film.objects.prefetch_related("reporters") return qs @@ -1117,9 +1105,10 @@ def test_should_preserve_prefetch_related(django_assert_num_queries): } """ schema = graphene.Schema(query=Query) + with django_assert_num_queries(3) as captured: result = schema.execute(query) - assert not result.errors + assert not result.errors def test_should_preserve_annotations(): @@ -1139,7 +1128,7 @@ def test_should_preserve_annotations(): class Query(graphene.ObjectType): films = DjangoConnectionField(FilmType) - def resolve_films(root, info): + def resolve_films(root, info, **kwargs): qs = Film.objects.prefetch_related("reporters") return qs.annotate(reporters_count=models.Count("reporters")) @@ -1172,3 +1161,4 @@ def test_should_preserve_annotations(): } } assert result.data == expected, str(result.data) + assert not result.errors diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 888521f..2a6d357 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -9,14 +9,10 @@ from graphene import Connection, Field, Interface, ObjectType, Schema, String from graphene.relay import Node from .. import registry -from ..settings import graphene_settings from ..types import DjangoObjectType, DjangoObjectTypeOptions -from ..converter import convert_choice_field_to_enum from .models import Article as ArticleModel from .models import Reporter as ReporterModel -registry.reset_global_registry() - class Reporter(DjangoObjectType): """Reporter description""" @@ -115,90 +111,171 @@ def test_django_objecttype_with_custom_meta(): def test_schema_representation(): - expected = """ -schema { - query: RootQuery -} + expected = dedent( + """\ + schema { + query: RootQuery + } -type Article implements Node { - id: ID! - headline: String! - pubDate: Date! - pubDateTime: DateTime! - reporter: Reporter! - editor: Reporter! - lang: ArticleLang! - importance: ArticleImportance -} + \"""Article description\""" + type Article implements Node { + \"""The ID of the object\""" + id: ID! -type ArticleConnection { - pageInfo: PageInfo! - edges: [ArticleEdge]! - test: String -} + \"""\""" + headline: String! -type ArticleEdge { - node: Article - cursor: String! -} + \"""\""" + pubDate: Date! -enum ArticleImportance { - A_1 - A_2 -} + \"""\""" + pubDateTime: DateTime! -enum ArticleLang { - ES - EN -} + \"""\""" + reporter: Reporter! -scalar Date + \"""\""" + editor: Reporter! -scalar DateTime + \"""Language\""" + lang: ArticleLang! -interface Node { - id: ID! -} + \"""\""" + importance: ArticleImportance + } -type PageInfo { - hasNextPage: Boolean! - hasPreviousPage: Boolean! - startCursor: String - endCursor: String -} + \"""An object with an ID\""" + interface Node { + \"""The ID of the object\""" + id: ID! + } -type Reporter { - id: ID! - firstName: String! - lastName: String! - email: String! - pets: [Reporter!]! - aChoice: ReporterAChoice - reporterType: ReporterReporterType - articles(before: String, after: String, first: Int, last: Int): ArticleConnection! -} + \""" + The `Date` scalar type represents a Date + value as specified by + [iso8601](https://en.wikipedia.org/wiki/ISO_8601). + \""" + scalar Date -enum ReporterAChoice { - A_1 - A_2 -} + \""" + The `DateTime` scalar type represents a DateTime + value as specified by + [iso8601](https://en.wikipedia.org/wiki/ISO_8601). + \""" + scalar DateTime -enum ReporterReporterType { - A_1 - A_2 -} + \"""An enumeration.\""" + enum ArticleLang { + \"""Spanish\""" + ES -type RootQuery { - node(id: ID!): Node -} -""".lstrip() + \"""English\""" + EN + } + + \"""An enumeration.\""" + enum ArticleImportance { + \"""Very important\""" + A_1 + + \"""Not as important\""" + A_2 + } + + \"""Reporter description\""" + type Reporter { + \"""\""" + id: ID! + + \"""\""" + firstName: String! + + \"""\""" + lastName: String! + + \"""\""" + email: String! + + \"""\""" + pets: [Reporter!]! + + \"""\""" + aChoice: ReporterAChoice + + \"""\""" + reporterType: ReporterReporterType + + \"""\""" + articles(before: String = null, after: String = null, first: Int = null, last: Int = null): ArticleConnection! + } + + \"""An enumeration.\""" + enum ReporterAChoice { + \"""this\""" + A_1 + + \"""that\""" + A_2 + } + + \"""An enumeration.\""" + enum ReporterReporterType { + \"""Regular\""" + A_1 + + \"""CNN Reporter\""" + A_2 + } + + type ArticleConnection { + \"""Pagination data for this connection.\""" + pageInfo: PageInfo! + + \"""Contains the nodes in this connection.\""" + edges: [ArticleEdge]! + test: String + } + + \""" + 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 `Article` and its cursor.\""" + type ArticleEdge { + \"""The item at the end of the edge\""" + node: Article + + \"""A cursor for use in pagination\""" + cursor: String! + } + + type RootQuery { + node( + \"""The ID of the object\""" + id: ID! + ): Node + } + """ + ) assert str(schema) == expected def with_local_registry(func): def inner(*args, **kwargs): old = registry.get_global_registry() - registry.reset_global_registry() try: retval = func(*args, **kwargs) except Exception as e: @@ -420,20 +497,21 @@ class TestDjangoObjectType: assert str(schema) == dedent( """\ - schema { - query: Query - } + type Query { + pet: Pet + } - type Pet { - id: ID! - kind: String! - cuteness: Int! - } + type Pet { + \"""\""" + id: ID! - type Query { - pet: Pet - } - """ + \"""\""" + kind: String! + + \"""\""" + cuteness: Int! + } + """ ) def test_django_objecttype_convert_choices_enum_list(self, PetModel): @@ -449,25 +527,30 @@ class TestDjangoObjectType: assert str(schema) == dedent( """\ - schema { - query: Query - } + type Query { + pet: Pet + } - type Pet { - id: ID! - kind: PetModelKind! - cuteness: Int! - } + type Pet { + \"""\""" + id: ID! - enum PetModelKind { - CAT - DOG - } + \"""\""" + kind: PetModelKind! - type Query { - pet: Pet - } - """ + \"""\""" + cuteness: Int! + } + + \"""An enumeration.\""" + enum PetModelKind { + \"""Cat\""" + CAT + + \"""Dog\""" + DOG + } + """ ) def test_django_objecttype_convert_choices_enum_empty_list(self, PetModel): @@ -483,23 +566,26 @@ class TestDjangoObjectType: assert str(schema) == dedent( """\ - schema { - query: Query - } + type Query { + pet: Pet + } - type Pet { - id: ID! - kind: String! - cuteness: Int! - } + type Pet { + \"""\""" + id: ID! - type Query { - pet: Pet - } - """ + \"""\""" + kind: String! + + \"""\""" + cuteness: Int! + } + """ ) - def test_django_objecttype_convert_choices_enum_naming_collisions(self, PetModel): + def test_django_objecttype_convert_choices_enum_naming_collisions( + self, PetModel, graphene_settings + ): graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = True class PetModelKind(DjangoObjectType): @@ -514,28 +600,32 @@ class TestDjangoObjectType: assert str(schema) == dedent( """\ - schema { - query: Query - } + type Query { + pet: PetModelKind + } - type PetModelKind { - id: ID! - kind: TestsPetModelKindChoices! - } + type PetModelKind { + \"""\""" + id: ID! - type Query { - pet: PetModelKind - } + \"""\""" + kind: TestsPetModelKindChoices! + } - enum TestsPetModelKindChoices { - CAT - DOG - } - """ + \"""An enumeration.\""" + enum TestsPetModelKindChoices { + \"""Cat\""" + CAT + + \"""Dog\""" + DOG + } + """ ) - graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = False - def test_django_objecttype_choices_custom_enum_name(self, PetModel): + def test_django_objecttype_choices_custom_enum_name( + self, PetModel, graphene_settings + ): graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME = ( "graphene_django.tests.test_types.custom_enum_name" ) @@ -552,24 +642,25 @@ class TestDjangoObjectType: assert str(schema) == dedent( """\ - schema { - query: Query - } + type Query { + pet: PetModelKind + } - enum CustomEnumKind { - CAT - DOG - } + type PetModelKind { + \"""\""" + id: ID! - type PetModelKind { - id: ID! - kind: CustomEnumKind! - } + \"""\""" + kind: CustomEnumKind! + } - type Query { - pet: PetModelKind - } - """ + \"""An enumeration.\""" + enum CustomEnumKind { + \"""Cat\""" + CAT + + \"""Dog\""" + DOG + } + """ ) - - graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME = None diff --git a/graphene_django/tests/test_utils.py b/graphene_django/tests/test_utils.py index 55cfd4f..c0d376b 100644 --- a/graphene_django/tests/test_utils.py +++ b/graphene_django/tests/test_utils.py @@ -1,6 +1,10 @@ -from django.utils.translation import gettext_lazy +import json -from ..utils import camelize, get_model_fields +import pytest +from django.utils.translation import gettext_lazy +from mock import patch + +from ..utils import camelize, get_model_fields, GraphQLTestCase from .models import Film, Reporter @@ -30,3 +34,27 @@ def test_camelize(): "valueA": "value_b" } assert camelize({0: {"field_a": ["errors"]}}) == {0: {"fieldA": ["errors"]}} + + +@pytest.mark.django_db +@patch("graphene_django.utils.testing.Client.post") +def test_graphql_test_case_op_name(post_mock): + """ + Test that `GraphQLTestCase.query()`'s `op_name` argument produces an `operationName` field. + """ + + class TestClass(GraphQLTestCase): + GRAPHQL_SCHEMA = True + + def runTest(self): + pass + + tc = TestClass() + tc.setUpClass() + tc.query("query { }", op_name="QueryName") + body = json.loads(post_mock.call_args.args[1]) + # `operationName` field from https://graphql.org/learn/serving-over-http/#post-request + assert ( + "operationName", + "QueryName", + ) in body.items(), "Field 'operationName' is not present in the final request." diff --git a/graphene_django/tests/test_views.py b/graphene_django/tests/test_views.py index db6cc4e..1c027d9 100644 --- a/graphene_django/tests/test_views.py +++ b/graphene_django/tests/test_views.py @@ -99,12 +99,14 @@ def test_reports_validation_errors(client): assert response_json(response) == { "errors": [ { - "message": 'Cannot query field "unknownOne" on type "QueryRoot".', + "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 9}], + "path": None, }, { - "message": 'Cannot query field "unknownTwo" on type "QueryRoot".', + "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 21}], + "path": None, }, ] } @@ -124,7 +126,9 @@ def test_errors_when_missing_operation_name(client): assert response_json(response) == { "errors": [ { - "message": "Must provide operation name if query contains multiple operations." + "message": "Must provide operation name if query contains multiple operations.", + "locations": None, + "path": None, } ] } @@ -464,8 +468,8 @@ def test_handles_syntax_errors_caught_by_graphql(client): "errors": [ { "locations": [{"column": 1, "line": 1}], - "message": "Syntax Error GraphQL (1:1) " - 'Unexpected Name "syntaxerror"\n\n1: syntaxerror\n ^\n', + "message": "Syntax Error: Unexpected Name 'syntaxerror'.", + "path": None, } ] } diff --git a/graphene_django/utils/testing.py b/graphene_django/utils/testing.py index 6365d81..0eba4fd 100644 --- a/graphene_django/utils/testing.py +++ b/graphene_django/utils/testing.py @@ -34,20 +34,20 @@ class GraphQLTestCase(TestCase): supply the op_name. For annon queries ("{ ... }"), should be None (default). input_data (dict) - If provided, the $input variable in GraphQL will be set - to this value. If both ``input_data`` and ``variables``, + to this value. If both ``input_data`` and ``variables``, are provided, the ``input`` field in the ``variables`` dict will be overwritten with this value. variables (dict) - If provided, the "variables" field in GraphQL will be set to this value. headers (dict) - If provided, the headers in POST request to GRAPHQL_URL - will be set to this value. + will be set to this value. Returns: Response object from client """ body = {"query": query} - if operation_name: - body["operationName"] = operation_name + if op_name: + body["operationName"] = op_name if variables: body["variables"] = variables if input_data: diff --git a/graphene_django/views.py b/graphene_django/views.py index 8d57d50..1a373c7 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -6,14 +6,14 @@ from django.http import HttpResponse, HttpResponseNotAllowed from django.http.response import HttpResponseBadRequest from django.shortcuts import render from django.utils.decorators import method_decorator -from django.views.generic import View from django.views.decorators.csrf import ensure_csrf_cookie - -from graphql import get_default_backend -from graphql.error import format_error as format_graphql_error +from django.views.generic import View +from graphql import OperationType, get_operation_ast, parse, validate from graphql.error import GraphQLError +from graphql.error import format_error as format_graphql_error from graphql.execution import ExecutionResult -from graphql.type.schema import GraphQLSchema + +from graphene import Schema from .settings import graphene_settings @@ -56,8 +56,6 @@ class GraphQLView(View): schema = None graphiql = False - executor = None - backend = None middleware = None root_value = None pretty = False @@ -66,35 +64,28 @@ class GraphQLView(View): def __init__( self, schema=None, - executor=None, middleware=None, root_value=None, graphiql=False, pretty=False, batch=False, - backend=None, ): if not schema: schema = graphene_settings.SCHEMA - if backend is None: - backend = get_default_backend() - if middleware is None: middleware = graphene_settings.MIDDLEWARE self.schema = self.schema or schema if middleware is not None: self.middleware = list(instantiate_middleware(middleware)) - self.executor = executor self.root_value = root_value self.pretty = self.pretty or pretty self.graphiql = self.graphiql or graphiql self.batch = self.batch or batch - self.backend = backend assert isinstance( - self.schema, GraphQLSchema + self.schema, Schema ), "A Schema is required to be provided to GraphQLView." assert not all((graphiql, batch)), "Use either graphiql or batch processing" @@ -108,9 +99,6 @@ class GraphQLView(View): def get_context(self, request): return request - def get_backend(self, request): - return self.backend - @method_decorator(ensure_csrf_cookie) def dispatch(self, request, *args, **kwargs): try: @@ -172,7 +160,9 @@ class GraphQLView(View): self.format_error(e) for e in execution_result.errors ] - if execution_result.invalid: + if execution_result.errors and any( + not e.path for e in execution_result.errors + ): status_code = 400 else: response["data"] = execution_result.data @@ -245,14 +235,13 @@ class GraphQLView(View): raise HttpError(HttpResponseBadRequest("Must provide query string.")) try: - backend = self.get_backend(request) - document = backend.document_from_string(self.schema, query) + document = parse(query) except Exception as e: - return ExecutionResult(errors=[e], invalid=True) + return ExecutionResult(errors=[e]) if request.method.lower() == "get": - operation_type = document.get_operation_type(operation_name) - if operation_type and operation_type != "query": + operation_ast = get_operation_ast(document, operation_name) + if operation_ast and operation_ast.operation != OperationType.QUERY: if show_graphiql: return None @@ -260,28 +249,23 @@ class GraphQLView(View): HttpResponseNotAllowed( ["POST"], "Can only perform a {} operation from a POST request.".format( - operation_type + operation_ast.operation.value ), ) ) - try: - extra_options = {} - if self.executor: - # We only include it optionally since - # executor is not a valid argument in all backends - extra_options["executor"] = self.executor + validation_errors = validate(self.schema.graphql_schema, document) + if validation_errors: + return ExecutionResult(data=None, errors=validation_errors) - return document.execute( - root_value=self.get_root_value(request), - variable_values=variables, - operation_name=operation_name, - context_value=self.get_context(request), - middleware=self.get_middleware(request), - **extra_options - ) - except Exception as e: - return ExecutionResult(errors=[e], invalid=True) + return self.schema.execute( + source=query, + root_value=self.get_root_value(request), + variable_values=variables, + operation_name=operation_name, + context_value=self.get_context(request), + middleware=self.get_middleware(request), + ) @classmethod def can_display_graphiql(cls, request, data): diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 4e47ff4..0000000 --- a/pytest.ini +++ /dev/null @@ -1,2 +0,0 @@ -[pytest] -DJANGO_SETTINGS_MODULE = django_test_settings diff --git a/setup.cfg b/setup.cfg index def0b67..d588786 100644 --- a/setup.cfg +++ b/setup.cfg @@ -43,3 +43,7 @@ include_trailing_comma=True force_grid_wrap=0 use_parentheses=True line_length=88 + +[tool:pytest] +DJANGO_SETTINGS_MODULE = django_test_settings +addopts = --random-order diff --git a/setup.py b/setup.py index 3639fb1..980871a 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,8 @@ -from setuptools import find_packages, setup import ast import re +from setuptools import find_packages, setup + _version_re = re.compile(r"__version__\s+=\s+(.*)") with open("graphene_django/__init__.py", "rb") as f: @@ -15,6 +16,7 @@ rest_framework_require = ["djangorestframework>=3.6.3"] tests_require = [ "pytest>=3.6.3", "pytest-cov", + "pytest-random-order", "coveralls", "mock", "pytz", @@ -52,9 +54,9 @@ setup( keywords="api graphql protocol rest relay graphene", packages=find_packages(exclude=["tests"]), install_requires=[ - "graphene>=2.1.7,<3", - "graphql-core>=2.1.0,<3", - "Django>=1.11,!=2.0.*,!=2.1.*", + "graphene>=3.0.0b1,<4", + "graphql-core>=3.1.0,<4", + "Django>=2.2", "promise>=2.1", ], setup_requires=["pytest-runner"], diff --git a/tox.ini b/tox.ini index 18bbd30..7e01ac9 100644 --- a/tox.ini +++ b/tox.ini @@ -1,11 +1,16 @@ [tox] envlist = - py{36,37,38}-django{111,22,30,master}, + py{36,37,38}-django{22,30,master}, black,flake8 -[travis:env] +[gh-actions] +python = + 3.6: py36 + 3.7: py37 + 3.8: py38 + +[gh-actions:env] DJANGO = - 1.11: django111 2.2: django22 3.0: django30 master: djangomaster @@ -18,7 +23,6 @@ setenv = deps = -e.[test] psycopg2-binary - django111: Django>=1.11,<2.0 django22: Django>=2.2,<3.0 django30: Django>=3.0a1,<3.1 djangomaster: https://github.com/django/django/archive/master.zip @@ -34,4 +38,4 @@ commands = basepython = python3.8 deps = -e.[dev] commands = - flake8 graphene_django examples + flake8 graphene_django examples setup.py