diff --git a/Makefile b/Makefile index 70badcb..b850ae8 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,29 @@ +.PHONY: dev-setup ## Install development dependencies dev-setup: pip install -e ".[dev]" +.PHONY: install-dev +install-dev: dev-setup # Alias install-dev -> dev-setup + +.PHONY: tests tests: py.test graphene_django --cov=graphene_django -vv -format: - black --exclude "/migrations/" graphene_django examples +.PHONY: test +test: tests # Alias test -> tests +.PHONY: format +format: + black --exclude "/migrations/" graphene_django examples setup.py + +.PHONY: lint lint: flake8 graphene_django examples + +.PHONY: docs ## Generate docs +docs: dev-setup + cd docs && make install && make html + +.PHONY: docs-live ## Generate docs with live reloading +docs-live: dev-setup + cd docs && make install && make livehtml diff --git a/README.md b/README.md index 159a592..33f71f3 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,12 @@ GRAPHENE = { We need to set up a `GraphQL` endpoint in our Django app, so we can serve the queries. ```python -from django.conf.urls import url +from django.urls import path from graphene_django.views import GraphQLView urlpatterns = [ # ... - url(r'^graphql$', GraphQLView.as_view(graphiql=True)), + path('graphql', GraphQLView.as_view(graphiql=True)), ] ``` @@ -100,4 +100,4 @@ To learn more check out the following [examples](examples/): ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) \ No newline at end of file +See [CONTRIBUTING.md](CONTRIBUTING.md) diff --git a/docs/Makefile b/docs/Makefile index 7da67c3..4ae2962 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -48,12 +48,20 @@ help: clean: rm -rf $(BUILDDIR)/* +.PHONY: install ## to install all documentation related requirements +install: + pip install -r requirements.txt + .PHONY: html html: $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html @echo @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." +.PHONY: livehtml ## to build and serve live-reloading documentation +livehtml: + sphinx-autobuild -b html --watch ../graphene_django $(ALLSPHINXOPTS) $(BUILDDIR)/html + .PHONY: dirhtml dirhtml: $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml diff --git a/docs/_static/.gitkeep b/docs/_static/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docs/authorization.rst b/docs/authorization.rst index 3d0bb8a..2c38fa4 100644 --- a/docs/authorization.rst +++ b/docs/authorization.rst @@ -154,7 +154,8 @@ Adding Login Required To restrict users from accessing the GraphQL API page the standard Django LoginRequiredMixin_ can be used to create your own standard Django Class Based View, which includes the ``LoginRequiredMixin`` and subclasses the ``GraphQLView``.: .. code:: python - #views.py + + # views.py from django.contrib.auth.mixins import LoginRequiredMixin from graphene_django.views import GraphQLView diff --git a/docs/mutations.rst b/docs/mutations.rst index 6610151..362df58 100644 --- a/docs/mutations.rst +++ b/docs/mutations.rst @@ -151,7 +151,7 @@ customize the look up with the ``lookup_field`` attribute on the ``SerializerMut .. code:: python from graphene_django.rest_framework.mutation import SerializerMutation - from .serializers imoprt MyModelSerializer + from .serializers import MyModelSerializer class AwesomeModelMutation(SerializerMutation): @@ -168,7 +168,7 @@ Use the method ``get_serializer_kwargs`` to override how updates are applied. .. code:: python from graphene_django.rest_framework.mutation import SerializerMutation - from .serializers imoprt MyModelSerializer + from .serializers import MyModelSerializer class AwesomeModelMutation(SerializerMutation): @@ -199,7 +199,7 @@ You can use relay with mutations. A Relay mutation must inherit from .. code:: python - import graphene + import graphene from graphene import relay from graphene_django import DjangoObjectType from graphql_relay import from_global_id diff --git a/docs/queries.rst b/docs/queries.rst index 0edd1dd..67ebb06 100644 --- a/docs/queries.rst +++ b/docs/queries.rst @@ -41,14 +41,18 @@ Full example return Question.objects.get(pk=question_id) -Fields ------- +Specifying which fields to include +---------------------------------- By default, ``DjangoObjectType`` will present all fields on a Model through GraphQL. -If you don't want to do this you can change this by setting either ``only_fields`` and ``exclude_fields``. +If you only want a subset of fields to be present, you can do so using +``fields`` or ``exclude``. It is strongly recommended that you explicitly set +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. -only_fields -~~~~~~~~~~~ +``fields`` +~~~~~~~~~~ Show **only** these fields on the model: @@ -57,24 +61,35 @@ Show **only** these fields on the model: class QuestionType(DjangoObjectType): class Meta: model = Question - only_fields = ('question_text') + fields = ('id', 'question_text') +You can also set the ``fields`` attribute to the special value ``'__all__'`` to indicate that all fields in the model should be used. -exclude_fields -~~~~~~~~~~~~~~ - -Show all fields **except** those in ``exclude_fields``: +For example: .. code:: python class QuestionType(DjangoObjectType): class Meta: model = Question - exclude_fields = ('question_text') + fields = '__all__' -Customised fields -~~~~~~~~~~~~~~~~~ +``exclude`` +~~~~~~~~~~~ + +Show all fields **except** those in ``exclude``: + +.. code:: python + + class QuestionType(DjangoObjectType): + class Meta: + model = Question + exclude = ('question_text',) + + +Customising fields +------------------ You can completely overwrite a field, or add new fields, to a ``DjangoObjectType`` using a Resolver: @@ -84,7 +99,7 @@ You can completely overwrite a field, or add new fields, to a ``DjangoObjectType class Meta: model = Question - exclude_fields = ('question_text') + fields = ('id', 'question_text') extra_field = graphene.String() @@ -92,6 +107,71 @@ You can completely overwrite a field, or add new fields, to a ``DjangoObjectType return 'hello!' +Choices to Enum conversion +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +By default Graphene-Django will convert any Django fields that have `choices`_ +defined into a GraphQL enum type. + +.. _choices: https://docs.djangoproject.com/en/2.2/ref/models/fields/#choices + +For example the following ``Model`` and ``DjangoObjectType``: + +.. code:: python + + class PetModel(models.Model): + kind = models.CharField(max_length=100, choices=(('cat', 'Cat'), ('dog', 'Dog'))) + + class Pet(DjangoObjectType): + class Meta: + model = PetModel + +Results in the following GraphQL schema definition: + +.. code:: + + type Pet { + id: ID! + kind: PetModelKind! + } + + enum PetModelKind { + CAT + DOG + } + +You can disable this automatic conversion by setting +``convert_choices_to_enum`` attribute to ``False`` on the ``DjangoObjectType`` +``Meta`` class. + +.. code:: python + + class Pet(DjangoObjectType): + class Meta: + model = PetModel + convert_choices_to_enum = False + +.. code:: + + type Pet { + id: ID! + kind: String! + } + +You can also set ``convert_choices_to_enum`` to a list of fields that should be +automatically converted into enums: + +.. code:: python + + class Pet(DjangoObjectType): + class Meta: + model = PetModel + convert_choices_to_enum = ['kind'] + +**Note:** Setting ``convert_choices_to_enum = []`` is the same as setting it to +``False``. + + Related models -------------- @@ -113,7 +193,7 @@ When ``Question`` is published as a ``DjangoObjectType`` and you want to add ``C class QuestionType(DjangoObjectType): class Meta: model = Question - only_fields = ('category',) + fields = ('category',) Then all query-able related models must be defined as DjangoObjectType subclass, or they will fail to show if you are trying to query those relation fields. You only diff --git a/docs/requirements.txt b/docs/requirements.txt index 220b7cf..7c89926 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,3 +1,4 @@ -sphinx +Sphinx==1.5.3 +sphinx-autobuild==0.7.1 # Docs template http://graphene-python.org/sphinx_graphene_theme.zip diff --git a/docs/settings.rst b/docs/settings.rst index 547e77f..4776ce0 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -30,7 +30,7 @@ Default: ``None`` ``SCHEMA_OUTPUT`` ----------- +----------------- The name of the file where the GraphQL schema output will go. @@ -44,7 +44,7 @@ Default: ``schema.json`` ``SCHEMA_INDENT`` ----------- +----------------- The indentation level of the schema output. @@ -58,7 +58,7 @@ Default: ``2`` ``MIDDLEWARE`` ----------- +-------------- A tuple of middleware that will be executed for each GraphQL query. @@ -76,7 +76,7 @@ Default: ``()`` ``RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST`` ----------- +------------------------------------------ Enforces relay queries to have the ``first`` or ``last`` argument. @@ -90,7 +90,7 @@ Default: ``False`` ``RELAY_CONNECTION_MAX_LIMIT`` ----------- +------------------------------ The maximum size of objects that can be requested through a relay connection. @@ -101,3 +101,42 @@ Default: ``100`` GRAPHENE = { 'RELAY_CONNECTION_MAX_LIMIT': 100, } + + +``CAMELCASE_ERRORS`` +------------------------------------ + +When set to ``True`` field names in the ``errors`` object will be camel case. +By default they will be snake case. + +Default: ``False`` + +.. code:: python + + GRAPHENE = { + 'CAMELCASE_ERRORS': False, + } + + # result = schema.execute(...) + print(result.errors) + # [ + # { + # 'field': 'test_field', + # 'messages': ['This field is required.'], + # } + # ] + +.. code:: python + + GRAPHENE = { + 'CAMELCASE_ERRORS': True, + } + + # result = schema.execute(...) + print(result.errors) + # [ + # { + # 'field': 'testField', + # 'messages': ['This field is required.'], + # } + # ] diff --git a/examples/cookbook-plain/requirements.txt b/examples/cookbook-plain/requirements.txt index 2154fd8..802aa37 100644 --- a/examples/cookbook-plain/requirements.txt +++ b/examples/cookbook-plain/requirements.txt @@ -1,4 +1,4 @@ graphene graphene-django graphql-core>=2.1rc1 -django==2.1.6 +django==2.2.4 diff --git a/examples/cookbook/cookbook/ingredients/models.py b/examples/cookbook/cookbook/ingredients/models.py index 6426dab..1e97226 100644 --- a/examples/cookbook/cookbook/ingredients/models.py +++ b/examples/cookbook/cookbook/ingredients/models.py @@ -11,7 +11,9 @@ class Category(models.Model): class Ingredient(models.Model): name = models.CharField(max_length=100) notes = models.TextField(null=True, blank=True) - category = models.ForeignKey(Category, related_name="ingredients") + category = models.ForeignKey( + Category, related_name="ingredients", on_delete=models.CASCADE + ) def __str__(self): return self.name diff --git a/examples/cookbook/cookbook/recipes/models.py b/examples/cookbook/cookbook/recipes/models.py index b98664c..0bfb434 100644 --- a/examples/cookbook/cookbook/recipes/models.py +++ b/examples/cookbook/cookbook/recipes/models.py @@ -10,8 +10,10 @@ class Recipe(models.Model): class RecipeIngredient(models.Model): - recipe = models.ForeignKey(Recipe, related_name="amounts") - ingredient = models.ForeignKey(Ingredient, related_name="used_by") + recipe = models.ForeignKey(Recipe, related_name="amounts", on_delete=models.CASCADE) + ingredient = models.ForeignKey( + Ingredient, related_name="used_by", on_delete=models.CASCADE + ) amount = models.FloatField() unit = models.CharField( max_length=20, diff --git a/examples/cookbook/cookbook/settings.py b/examples/cookbook/cookbook/settings.py index ed41a65..7eb9d56 100644 --- a/examples/cookbook/cookbook/settings.py +++ b/examples/cookbook/cookbook/settings.py @@ -43,13 +43,12 @@ INSTALLED_APPS = [ "cookbook.recipes.apps.RecipesConfig", ] -MIDDLEWARE_CLASSES = [ +MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", - "django.contrib.auth.middleware.SessionAuthenticationMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware", ] diff --git a/examples/cookbook/requirements.txt b/examples/cookbook/requirements.txt index fe0527a..0537103 100644 --- a/examples/cookbook/requirements.txt +++ b/examples/cookbook/requirements.txt @@ -1,5 +1,5 @@ graphene graphene-django graphql-core>=2.1rc1 -django==1.11.20 +django==2.2.4 django-filter>=2 diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 51acfd2..659cc79 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -1,6 +1,6 @@ from .types import DjangoObjectType from .fields import DjangoConnectionField -__version__ = "2.3.0" +__version__ = "2.5.0" __all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 1bb16f4..063d6be 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -52,13 +52,15 @@ def get_choices(choices): yield name, value, description -def convert_django_field_with_choices(field, registry=None): +def convert_django_field_with_choices( + field, registry=None, convert_choices_to_enum=True +): if registry is not None: converted = registry.get_converted_field(field) if converted: return converted choices = getattr(field, "choices", None) - if choices: + if choices and convert_choices_to_enum: meta = field.model._meta name = to_camel_case("{}_{}".format(meta.object_name, field.name)) choices = list(get_choices(choices)) @@ -71,7 +73,8 @@ def convert_django_field_with_choices(field, registry=None): return named_choices_descriptions[self.name] enum = Enum(name, list(named_choices), type=EnumWithDescriptionsType) - converted = enum(description=field.help_text, required=not field.null) + required = not (field.blank or field.null) + converted = enum(description=field.help_text, required=required) else: converted = convert_django_field(field, registry) if registry is not None: @@ -192,11 +195,17 @@ def convert_field_to_list_or_connection(field, registry=None): if _type._meta.filter_fields or _type._meta.filterset_class: from .filter.fields import DjangoFilterConnectionField - return DjangoFilterConnectionField(_type, description=description) + return DjangoFilterConnectionField( + _type, required=True, description=description + ) - return DjangoConnectionField(_type, description=description) + return DjangoConnectionField(_type, required=True, description=description) - return DjangoListField(_type, description=description) + return DjangoListField( + _type, + required=True, # A Set is always returned, never None. + description=description, + ) return Dynamic(dynamic_type) diff --git a/graphene_django/debug/middleware.py b/graphene_django/debug/middleware.py index 48d471f..0fe3fe3 100644 --- a/graphene_django/debug/middleware.py +++ b/graphene_django/debug/middleware.py @@ -16,14 +16,18 @@ class DjangoDebugContext(object): 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 on_resolve_all_promises(self, values): + if self.promises: + self.debug_promise = None + return self.get_debug_promise() self.disable_instrumentation() return self.object def add_promise(self, promise): - if self.debug_promise and not self.debug_promise.is_fulfilled: + if self.debug_promise: self.promises.append(promise) def enable_instrumentation(self): diff --git a/graphene_django/debug/tests/test_query.py b/graphene_django/debug/tests/test_query.py index af69715..db8f275 100644 --- a/graphene_django/debug/tests/test_query.py +++ b/graphene_django/debug/tests/test_query.py @@ -60,6 +60,73 @@ def test_should_query_field(): assert result.data == expected +def test_should_query_nested_field(): + r1 = Reporter(last_name="ABA") + r1.save() + r2 = Reporter(last_name="Griffin") + r2.save() + r2.pets.add(r1) + r1.pets.add(r2) + + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + + class Query(graphene.ObjectType): + reporter = graphene.Field(ReporterType) + debug = graphene.Field(DjangoDebug, name="__debug") + + def resolve_reporter(self, info, **args): + return Reporter.objects.first() + + query = """ + query ReporterQuery { + reporter { + lastName + pets { edges { node { + lastName + pets { edges { node { lastName } } } + } } } + } + __debug { + sql { + rawSql + } + } + } + """ + expected = { + "reporter": { + "lastName": "ABA", + "pets": { + "edges": [ + { + "node": { + "lastName": "Griffin", + "pets": {"edges": [{"node": {"lastName": "ABA"}}]}, + } + } + ] + }, + } + } + schema = graphene.Schema(query=Query) + result = schema.execute( + query, context_value=context(), middleware=[DjangoDebugMiddleware()] + ) + 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["reporter"] == expected["reporter"] + + def test_should_query_list(): r1 = Reporter(last_name="ABA") r1.save() diff --git a/graphene_django/fields.py b/graphene_django/fields.py index f865ca5..14022a9 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -15,7 +15,8 @@ from .utils import maybe_queryset class DjangoListField(Field): def __init__(self, _type, *args, **kwargs): - super(DjangoListField, self).__init__(List(_type), *args, **kwargs) + # Django would never return a Set of None vvvvvvv + super(DjangoListField, self).__init__(List(NonNull(_type)), *args, **kwargs) @property def model(self): @@ -100,7 +101,7 @@ class DjangoConnectionField(ConnectionField): iterable = default_manager iterable = maybe_queryset(iterable) if isinstance(iterable, QuerySet): - if iterable is not default_manager: + if iterable.model.objects is not default_manager: default_queryset = maybe_queryset(default_manager) iterable = cls.merge_querysets(default_queryset, iterable) _len = iterable.count() diff --git a/graphene_django/filter/fields.py b/graphene_django/filter/fields.py index 62f4b1a..338becb 100644 --- a/graphene_django/filter/fields.py +++ b/graphene_django/filter/fields.py @@ -111,7 +111,7 @@ class DjangoFilterConnectionField(DjangoConnectionField): return partial( self.connection_resolver, parent_resolver, - self.type, + self.connection_type, self.get_manager(), self.max_limit, self.enforce_first_or_last, diff --git a/graphene_django/filter/tests/test_fields.py b/graphene_django/filter/tests/test_fields.py index f949579..aa6a903 100644 --- a/graphene_django/filter/tests/test_fields.py +++ b/graphene_django/filter/tests/test_fields.py @@ -1,19 +1,17 @@ from datetime import datetime +from textwrap import dedent 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.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.utils import DJANGO_FILTER_INSTALLED -# for annotation test -from django.db.models import TextField, Value -from django.db.models.functions import Concat - pytestmark = [] if DJANGO_FILTER_INSTALLED: @@ -184,7 +182,7 @@ def test_filter_shortcut_filterset_context(): } """ schema = Schema(query=Query) - result = schema.execute(query, context_value=context()) + result = schema.execute(query, context=context()) assert not result.errors assert len(result.data["contextArticles"]["edges"]) == 1 @@ -322,12 +320,14 @@ def test_filter_filterset_related_results(): pub_date=datetime.now(), pub_date_time=datetime.now(), reporter=r1, + editor=r1, ) Article.objects.create( headline="a2", pub_date=datetime.now(), pub_date_time=datetime.now(), reporter=r2, + editor=r2, ) query = """ @@ -451,7 +451,7 @@ def test_global_id_multiple_field_explicit_reverse(): assert multiple_filter.field_class == GlobalIDMultipleChoiceField -def test_filter_filterset_related_results(): +def test_filter_filterset_related_results_with_filter(): class ReporterFilterNode(DjangoObjectType): class Meta: model = Reporter @@ -461,15 +461,15 @@ def test_filter_filterset_related_results(): class Query(ObjectType): all_reporters = DjangoFilterConnectionField(ReporterFilterNode) - r1 = Reporter.objects.create( + Reporter.objects.create( first_name="A test user", last_name="Last Name", email="test1@test.com" ) - r2 = Reporter.objects.create( + Reporter.objects.create( first_name="Other test user", last_name="Other Last Name", email="test2@test.com", ) - r3 = Reporter.objects.create( + Reporter.objects.create( first_name="Random", last_name="RandomLast", email="random@test.com" ) @@ -637,7 +637,7 @@ def test_should_query_filter_node_double_limit_raises(): Reporter.objects.create( first_name="Bob", last_name="Doe", email="bobdoe@example.com", a_choice=2 ) - r = Reporter.objects.create( + Reporter.objects.create( first_name="John", last_name="Doe", email="johndoe@example.com", a_choice=1 ) @@ -683,7 +683,7 @@ def test_order_by_is_perserved(): return reporters Reporter.objects.create(first_name="b") - r = Reporter.objects.create(first_name="a") + Reporter.objects.create(first_name="a") schema = Schema(query=Query) query = """ @@ -768,36 +768,154 @@ def test_annotation_is_perserved(): assert result.data == expected -def test_filter_with_union(): - class ReporterType(DjangoObjectType): +def test_integer_field_filter_type(): + class PetType(DjangoObjectType): class Meta: - model = Reporter + model = Pet interfaces = (Node,) - filter_fields = ("first_name",) + filter_fields = {"age": ["exact"]} + fields = ("age",) class Query(ObjectType): - all_reporters = DjangoFilterConnectionField(ReporterType) - - @classmethod - def resolve_all_reporters(cls, root, info, **kwargs): - ret = Reporter.objects.none() | Reporter.objects.filter(first_name="John") - - Reporter.objects.create(first_name="John", last_name="Doe") + pets = DjangoFilterConnectionField(PetType) schema = Schema(query=Query) - query = """ + 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 PetTypeConnection { + pageInfo: PageInfo! + edges: [PetTypeEdge]! + } + + type PetTypeEdge { + node: PetType + cursor: String! + } + + type Query { + pets(before: String, after: String, first: Int, last: Int, age: Int): PetTypeConnection + } + """ + ) + + +def test_filter_filterset_based_on_mixin(): + class ArticleFilterMixin(FilterSet): + @classmethod + def get_filters(cls): + filters = super(FilterSet, cls).get_filters() + filters.update( + { + "viewer__email__in": django_filters.CharFilter( + method="filter_email_in", field_name="reporter__email__in" + ) + } + ) + + return filters + + def filter_email_in(cls, queryset, name, value): + return queryset.filter(**{name: [value]}) + + class NewArticleFilter(ArticleFilterMixin, ArticleFilter): + pass + + class NewReporterNode(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (Node,) + + class NewArticleFilterNode(DjangoObjectType): + viewer = Field(NewReporterNode) + + class Meta: + model = Article + interfaces = (Node,) + filterset_class = NewArticleFilter + + def resolve_viewer(self, info): + return self.reporter + + class Query(ObjectType): + all_articles = DjangoFilterConnectionField(NewArticleFilterNode) + + reporter_1 = Reporter.objects.create( + first_name="John", last_name="Doe", email="john@doe.com" + ) + + article_1 = Article.objects.create( + headline="Hello", + reporter=reporter_1, + editor=reporter_1, + pub_date=datetime.now(), + pub_date_time=datetime.now(), + ) + + reporter_2 = Reporter.objects.create( + first_name="Adam", last_name="Doe", email="adam@doe.com" + ) + + article_2 = Article.objects.create( + headline="Good Bye", + reporter=reporter_2, + editor=reporter_2, + pub_date=datetime.now(), + pub_date_time=datetime.now(), + ) + + schema = Schema(query=Query) + + query = ( + """ query NodeFilteringQuery { - allReporters(firstName: "abc") { + allArticles(viewer_Email_In: "%s") { edges { node { - firstName + headline + viewer { + email + } } } } } """ - expected = {"allReporters": {"edges": []}} + % reporter_1.email + ) + + expected = { + "allArticles": { + "edges": [ + { + "node": { + "headline": article_1.headline, + "viewer": {"email": reporter_1.email}, + } + } + ] + } + } result = schema.execute(query) diff --git a/graphene_django/filter/utils.py b/graphene_django/filter/utils.py index cfa5621..81efb63 100644 --- a/graphene_django/filter/utils.py +++ b/graphene_django/filter/utils.py @@ -11,8 +11,29 @@ def get_filtering_args_from_filterset(filterset_class, type): from ..forms.converter import convert_form_field args = {} + model = filterset_class._meta.model for name, filter_field in six.iteritems(filterset_class.base_filters): - field_type = convert_form_field(filter_field.field).Argument() + form_field = None + + if name in filterset_class.declared_filters: + form_field = filter_field.field + else: + field_name = name.split("__", 1)[0] + + if hasattr(model, field_name): + model_field = model._meta.get_field(field_name) + + if hasattr(model_field, "formfield"): + form_field = model_field.formfield( + required=filter_field.extra.get("required", False) + ) + + # 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 + + field_type = convert_form_field(form_field).Argument() field_type.description = filter_field.label args[name] = field_type diff --git a/graphene_django/forms/mutation.py b/graphene_django/forms/mutation.py index 0851a75..f5921e8 100644 --- a/graphene_django/forms/mutation.py +++ b/graphene_django/forms/mutation.py @@ -13,8 +13,8 @@ from graphene.types.mutation import MutationOptions from graphene.types.utils import yank_fields_from_attrs from graphene_django.registry import get_global_registry -from .converter import convert_form_field from ..types import ErrorType +from .converter import convert_form_field def fields_for_form(form, only_fields, exclude_fields): @@ -45,10 +45,7 @@ class BaseDjangoFormMutation(ClientIDMutation): if form.is_valid(): return cls.perform_mutate(form, info) else: - errors = [ - ErrorType(field=key, messages=value) - for key, value in form.errors.items() - ] + errors = ErrorType.from_errors(form.errors) return cls(errors=errors) diff --git a/graphene_django/forms/tests/test_mutation.py b/graphene_django/forms/tests/test_mutation.py index 543e89e..2de5113 100644 --- a/graphene_django/forms/tests/test_mutation.py +++ b/graphene_django/forms/tests/test_mutation.py @@ -2,7 +2,9 @@ from django import forms from django.test import TestCase from py.test import raises -from graphene_django.tests.models import Pet, Film, FilmDetails +from graphene_django.tests.models import Film, FilmDetails, Pet + +from ...settings import graphene_settings from ..mutation import DjangoFormMutation, DjangoModelFormMutation @@ -41,6 +43,22 @@ def test_has_input_fields(): assert "text" in MyMutation.Input._meta.fields +def test_mutation_error_camelcased(): + class ExtraPetForm(PetForm): + test_field = forms.CharField(required=True) + + class PetMutation(DjangoModelFormMutation): + 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 + + class ModelFormMutationTests(TestCase): def test_default_meta_fields(self): class PetMutation(DjangoModelFormMutation): diff --git a/graphene_django/forms/types.py b/graphene_django/forms/types.py index 1fe33f3..5005040 100644 --- a/graphene_django/forms/types.py +++ b/graphene_django/forms/types.py @@ -1,6 +1,3 @@ import graphene - -class ErrorType(graphene.ObjectType): - field = graphene.String() - messages = graphene.List(graphene.String) +from ..types import ErrorType # noqa Import ErrorType for backwards compatability diff --git a/graphene_django/rest_framework/mutation.py b/graphene_django/rest_framework/mutation.py index 0fe9a02..d9c695e 100644 --- a/graphene_django/rest_framework/mutation.py +++ b/graphene_django/rest_framework/mutation.py @@ -3,13 +3,13 @@ from collections import OrderedDict from django.shortcuts import get_object_or_404 import graphene +from graphene.relay.mutation import ClientIDMutation from graphene.types import Field, InputField from graphene.types.mutation import MutationOptions -from graphene.relay.mutation import ClientIDMutation from graphene.types.objecttype import yank_fields_from_attrs -from .serializer_converter import convert_serializer_field from ..types import ErrorType +from .serializer_converter import convert_serializer_field class SerializerMutationOptions(MutationOptions): @@ -52,7 +52,7 @@ class SerializerMutation(ClientIDMutation): lookup_field=None, serializer_class=None, model_class=None, - model_operations=["create", "update"], + model_operations=("create", "update"), only_fields=(), exclude_fields=(), **options @@ -127,10 +127,7 @@ class SerializerMutation(ClientIDMutation): if serializer.is_valid(): return cls.perform_mutate(serializer, info) else: - errors = [ - ErrorType(field=key, messages=value) - for key, value in serializer.errors.items() - ] + errors = ErrorType.from_errors(serializer.errors) return cls(errors=errors) diff --git a/graphene_django/rest_framework/serializer_converter.py b/graphene_django/rest_framework/serializer_converter.py index 9f8e516..c419419 100644 --- a/graphene_django/rest_framework/serializer_converter.py +++ b/graphene_django/rest_framework/serializer_converter.py @@ -57,18 +57,27 @@ def convert_serializer_field(field, is_input=True): def convert_serializer_to_input_type(serializer_class): + cached_type = convert_serializer_to_input_type.cache.get( + serializer_class.__name__, None + ) + if cached_type: + return cached_type serializer = serializer_class() items = { name: convert_serializer_field(field) for name, field in serializer.fields.items() } - - return type( + ret_type = type( "{}Input".format(serializer.__class__.__name__), (graphene.InputObjectType,), items, ) + convert_serializer_to_input_type.cache[serializer_class.__name__] = ret_type + return ret_type + + +convert_serializer_to_input_type.cache = {} @get_graphene_type_from_serializer_field.register(serializers.Field) diff --git a/graphene_django/rest_framework/tests/test_multiple_model_serializers.py b/graphene_django/rest_framework/tests/test_multiple_model_serializers.py new file mode 100644 index 0000000..c1f4626 --- /dev/null +++ b/graphene_django/rest_framework/tests/test_multiple_model_serializers.py @@ -0,0 +1,67 @@ +import graphene +import pytest +from django.db import models +from graphene import Schema +from rest_framework import serializers + +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) + created = models.DateTimeField(auto_now_add=True) + + +class MyFakeParentModel(models.Model): + name = models.CharField(max_length=50) + created = models.DateTimeField(auto_now_add=True) + child1 = models.OneToOneField( + MyFakeChildModel, related_name="parent1", on_delete=models.CASCADE + ) + child2 = models.OneToOneField( + MyFakeChildModel, related_name="parent2", on_delete=models.CASCADE + ) + + +class ParentType(DjangoObjectType): + class Meta: + model = MyFakeParentModel + interfaces = (graphene.relay.Node,) + + +class ChildType(DjangoObjectType): + class Meta: + model = MyFakeChildModel + interfaces = (graphene.relay.Node,) + + +class MyModelChildSerializer(serializers.ModelSerializer): + class Meta: + model = MyFakeChildModel + fields = "__all__" + + +class MyModelParentSerializer(serializers.ModelSerializer): + child1 = MyModelChildSerializer() + child2 = MyModelChildSerializer() + + class Meta: + model = MyFakeParentModel + fields = "__all__" + + +class MyParentModelMutation(SerializerMutation): + class Meta: + serializer_class = MyModelParentSerializer + + +class Mutation(graphene.ObjectType): + createParentWithChild = MyParentModelMutation.Field() + + +def test_create_schema(): + schema = Schema(mutation=Mutation, types=[ParentType, ChildType]) + assert schema diff --git a/graphene_django/rest_framework/tests/test_mutation.py b/graphene_django/rest_framework/tests/test_mutation.py index 9621ee3..9d8b950 100644 --- a/graphene_django/rest_framework/tests/test_mutation.py +++ b/graphene_django/rest_framework/tests/test_mutation.py @@ -1,11 +1,12 @@ import datetime +from py.test import mark, raises +from rest_framework import serializers + from graphene import Field, ResolveInfo from graphene.types.inputobjecttype import InputObjectType -from py.test import raises -from py.test import mark -from rest_framework import serializers +from ...settings import graphene_settings from ...types import DjangoObjectType from ..models import MyFakeModel, MyFakeModelWithPassword from ..mutation import SerializerMutation @@ -213,6 +214,13 @@ def test_model_mutate_and_get_payload_error(): assert len(result.errors) > 0 +def test_mutation_error_camelcased(): + 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(): with raises(Exception) as exc: diff --git a/graphene_django/settings.py b/graphene_django/settings.py index e5fad78..af63890 100644 --- a/graphene_django/settings.py +++ b/graphene_django/settings.py @@ -35,6 +35,7 @@ DEFAULTS = { "RELAY_CONNECTION_ENFORCE_FIRST_OR_LAST": False, # Max items returned in ConnectionFields / FilterConnectionFields "RELAY_CONNECTION_MAX_LIMIT": 100, + "CAMELCASE_ERRORS": False, } if settings.DEBUG: diff --git a/graphene_django/tests/models.py b/graphene_django/tests/models.py index b4eb3ce..14a8367 100644 --- a/graphene_django/tests/models.py +++ b/graphene_django/tests/models.py @@ -38,7 +38,7 @@ class Reporter(models.Model): last_name = models.CharField(max_length=30) email = models.EmailField() pets = models.ManyToManyField("self") - a_choice = models.CharField(max_length=30, choices=CHOICES) + a_choice = models.CharField(max_length=30, choices=CHOICES, blank=True) objects = models.Manager() doe_objects = DoeReporterManager() diff --git a/graphene_django/tests/test_converter.py b/graphene_django/tests/test_converter.py index bb176b3..3790c4a 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -1,6 +1,7 @@ import pytest from django.db import models from django.utils.translation import ugettext_lazy as _ +from graphene import NonNull from py.test import raises import graphene @@ -196,6 +197,23 @@ def test_field_with_choices_collision(): convert_django_field_with_choices(field) +def test_field_with_choices_convert_enum_false(): + field = models.CharField( + help_text="Language", choices=(("es", "Spanish"), ("en", "English")) + ) + + class TranslatedModel(models.Model): + language = field + + class Meta: + app_label = "test" + + graphene_type = convert_django_field_with_choices( + field, convert_choices_to_enum=False + ) + assert isinstance(graphene_type, graphene.String) + + def test_should_float_convert_float(): assert_conversion(models.FloatField, graphene.Float) @@ -217,8 +235,12 @@ def test_should_manytomany_convert_connectionorlist_list(): assert isinstance(graphene_field, graphene.Dynamic) dynamic_field = graphene_field.get_type() assert isinstance(dynamic_field, graphene.Field) - assert isinstance(dynamic_field.type, graphene.List) - assert dynamic_field.type.of_type == A + # A NonNull List of NonNull A ([A!]!) + # https://github.com/graphql-python/graphene-django/issues/448 + assert isinstance(dynamic_field.type, NonNull) + assert isinstance(dynamic_field.type.of_type, graphene.List) + assert isinstance(dynamic_field.type.of_type.of_type, NonNull) + assert dynamic_field.type.of_type.of_type.of_type == A def test_should_manytomany_convert_connectionorlist_connection(): @@ -233,7 +255,7 @@ def test_should_manytomany_convert_connectionorlist_connection(): assert isinstance(graphene_field, graphene.Dynamic) dynamic_field = graphene_field.get_type() assert isinstance(dynamic_field, ConnectionField) - assert dynamic_field.type == A._meta.connection + assert dynamic_field.type.of_type == A._meta.connection def test_should_manytoone_convert_connectionorlist(): @@ -245,8 +267,11 @@ def test_should_manytoone_convert_connectionorlist(): assert isinstance(graphene_field, graphene.Dynamic) dynamic_field = graphene_field.get_type() assert isinstance(dynamic_field, graphene.Field) - assert isinstance(dynamic_field.type, graphene.List) - assert dynamic_field.type.of_type == A + # a NonNull List of NonNull A ([A!]!) + assert isinstance(dynamic_field.type, NonNull) + assert isinstance(dynamic_field.type.of_type, graphene.List) + assert isinstance(dynamic_field.type.of_type.of_type, NonNull) + assert dynamic_field.type.of_type.of_type.of_type == A def test_should_onetoone_reverse_convert_model(): diff --git a/graphene_django/tests/test_query.py b/graphene_django/tests/test_query.py index 71f0f87..eac4481 100644 --- a/graphene_django/tests/test_query.py +++ b/graphene_django/tests/test_query.py @@ -1,23 +1,22 @@ import base64 import datetime +import graphene import pytest from django.db import models +from django.db.models import Q from django.utils.functional import SimpleLazyObject +from graphene.relay import Node 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 +from ..types import DjangoObjectType +from ..utils import DJANGO_FILTER_INSTALLED +from .models import Article, CNNReporter, Film, FilmDetails, Reporter pytestmark = pytest.mark.django_db @@ -28,7 +27,7 @@ def test_should_query_only_fields(): class ReporterType(DjangoObjectType): class Meta: model = Reporter - only_fields = ("articles",) + fields = ("articles",) schema = graphene.Schema(query=ReporterType) query = """ @@ -44,7 +43,7 @@ def test_should_query_simplelazy_objects(): class ReporterType(DjangoObjectType): class Meta: model = Reporter - only_fields = ("id",) + fields = ("id",) class Query(graphene.ObjectType): reporter = graphene.Field(ReporterType) @@ -289,7 +288,7 @@ def test_should_query_connectionfields(): class Meta: model = Reporter interfaces = (Node,) - only_fields = ("articles",) + fields = ("articles",) class Query(graphene.ObjectType): all_reporters = DjangoConnectionField(ReporterType) @@ -329,7 +328,7 @@ def test_should_keep_annotations(): class Meta: model = Reporter interfaces = (Node,) - only_fields = ("articles",) + fields = ("articles",) class ArticleType(DjangoObjectType): class Meta: @@ -1236,3 +1235,54 @@ def test_should_resolve_get_queryset_connectionfields(): result = schema.execute(query) assert not result.errors assert result.data == expected + + +def test_should_preserve_prefetch_related(django_assert_num_queries): + class ReporterType(DjangoObjectType): + class Meta: + model = Reporter + interfaces = (graphene.relay.Node,) + + class FilmType(DjangoObjectType): + reporters = DjangoConnectionField(ReporterType) + + class Meta: + model = Film + interfaces = (graphene.relay.Node,) + + class Query(graphene.ObjectType): + films = DjangoConnectionField(FilmType) + + def resolve_films(root, info, **kwargs): + qs = Film.objects.prefetch_related("reporters") + return qs + + r1 = Reporter.objects.create(first_name="Dave", last_name="Smith") + r2 = Reporter.objects.create(first_name="Jane", last_name="Doe") + + f1 = Film.objects.create() + f1.reporters.set([r1, r2]) + f2 = Film.objects.create() + f2.reporters.set([r2]) + + query = """ + query { + films { + edges { + node { + reporters { + edges { + node { + firstName + } + } + } + } + } + } + } + """ + schema = graphene.Schema(query=Query) + with django_assert_num_queries(3) as captured: + result = schema.execute(query) + assert not result.errors diff --git a/graphene_django/tests/test_schema.py b/graphene_django/tests/test_schema.py index 452449b..2c2f74b 100644 --- a/graphene_django/tests/test_schema.py +++ b/graphene_django/tests/test_schema.py @@ -48,6 +48,6 @@ def test_should_map_only_few_fields(): class Reporter2(DjangoObjectType): class Meta: model = Reporter - only_fields = ("id", "email") + fields = ("id", "email") assert list(Reporter2._meta.fields.keys()) == ["id", "email"] diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 8a8643b..5e9d1c2 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -1,6 +1,11 @@ +from collections import OrderedDict, defaultdict +from textwrap import dedent + +import pytest +from django.db import models from mock import patch -from graphene import Interface, ObjectType, Schema, Connection, String +from graphene import Connection, Field, Interface, ObjectType, Schema, String from graphene.relay import Node from .. import registry @@ -165,10 +170,10 @@ type Reporter { firstName: String! lastName: String! email: String! - pets: [Reporter] - aChoice: ReporterAChoice! + pets: [Reporter!]! + aChoice: ReporterAChoice reporterType: ReporterReporterType - articles(before: String, after: String, first: Int, last: Int): ArticleConnection + articles(before: String, after: String, first: Int, last: Int): ArticleConnection! } enum ReporterAChoice { @@ -206,21 +211,216 @@ def with_local_registry(func): @with_local_registry def test_django_objecttype_only_fields(): - class Reporter(DjangoObjectType): - class Meta: - model = ReporterModel - only_fields = ("id", "email", "films") + with pytest.warns(PendingDeprecationWarning): + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + only_fields = ("id", "email", "films") fields = list(Reporter._meta.fields.keys()) assert fields == ["id", "email", "films"] @with_local_registry -def test_django_objecttype_exclude_fields(): +def test_django_objecttype_fields(): class Reporter(DjangoObjectType): class Meta: model = ReporterModel - exclude_fields = "email" + fields = ("id", "email", "films") + + fields = list(Reporter._meta.fields.keys()) + assert fields == ["id", "email", "films"] + + +@with_local_registry +def test_django_objecttype_only_fields_and_fields(): + with pytest.raises(Exception): + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + only_fields = ("id", "email", "films") + fields = ("id", "email", "films") + + +@with_local_registry +def test_django_objecttype_all_fields(): + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = "__all__" + + fields = list(Reporter._meta.fields.keys()) + assert len(fields) == len(ReporterModel._meta.get_fields()) + + +@with_local_registry +def test_django_objecttype_exclude_fields(): + with pytest.warns(PendingDeprecationWarning): + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + exclude_fields = ["email"] fields = list(Reporter._meta.fields.keys()) assert "email" not in fields + + +@with_local_registry +def test_django_objecttype_exclude(): + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + exclude = ["email"] + + fields = list(Reporter._meta.fields.keys()) + assert "email" not in fields + + +@with_local_registry +def test_django_objecttype_exclude_fields_and_exclude(): + with pytest.raises(Exception): + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + exclude = ["email"] + exclude_fields = ["email"] + + +@with_local_registry +def test_django_objecttype_exclude_and_only(): + with pytest.raises(AssertionError): + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + exclude = ["email"] + fields = ["id"] + + +@with_local_registry +def test_django_objecttype_fields_exclude_type_checking(): + with pytest.raises(TypeError): + + class Reporter(DjangoObjectType): + class Meta: + model = ReporterModel + fields = "foo" + + with pytest.raises(TypeError): + + class Reporter2(DjangoObjectType): + class Meta: + model = ReporterModel + fields = "foo" + + +class TestDjangoObjectType: + @pytest.fixture + def PetModel(self): + class PetModel(models.Model): + kind = models.CharField(choices=(("cat", "Cat"), ("dog", "Dog"))) + cuteness = models.IntegerField( + choices=((1, "Kind of cute"), (2, "Pretty cute"), (3, "OMG SO CUTE!!!")) + ) + + yield PetModel + + # Clear Django model cache so we don't get warnings when creating the + # model multiple times + PetModel._meta.apps.all_models = defaultdict(OrderedDict) + + def test_django_objecttype_convert_choices_enum_false(self, PetModel): + class Pet(DjangoObjectType): + class Meta: + model = PetModel + convert_choices_to_enum = False + + class Query(ObjectType): + pet = Field(Pet) + + schema = Schema(query=Query) + + assert str(schema) == dedent( + """\ + schema { + query: Query + } + + type Pet { + id: ID! + kind: String! + cuteness: Int! + } + + type Query { + pet: Pet + } + """ + ) + + def test_django_objecttype_convert_choices_enum_list(self, PetModel): + class Pet(DjangoObjectType): + class Meta: + model = PetModel + convert_choices_to_enum = ["kind"] + + class Query(ObjectType): + pet = Field(Pet) + + schema = Schema(query=Query) + + assert str(schema) == dedent( + """\ + schema { + query: Query + } + + type Pet { + id: ID! + kind: PetModelKind! + cuteness: Int! + } + + enum PetModelKind { + CAT + DOG + } + + type Query { + pet: Pet + } + """ + ) + + def test_django_objecttype_convert_choices_enum_empty_list(self, PetModel): + class Pet(DjangoObjectType): + class Meta: + model = PetModel + convert_choices_to_enum = [] + + class Query(ObjectType): + pet = Field(Pet) + + schema = Schema(query=Query) + + assert str(schema) == dedent( + """\ + schema { + query: Query + } + + type Pet { + id: ID! + kind: String! + cuteness: Int! + } + + type Query { + pet: Pet + } + """ + ) diff --git a/graphene_django/tests/test_utils.py b/graphene_django/tests/test_utils.py index becd031..55cfd4f 100644 --- a/graphene_django/tests/test_utils.py +++ b/graphene_django/tests/test_utils.py @@ -1,4 +1,6 @@ -from ..utils import get_model_fields +from django.utils.translation import gettext_lazy + +from ..utils import camelize, get_model_fields from .models import Film, Reporter @@ -10,3 +12,21 @@ def test_get_model_fields_no_duplication(): film_fields = get_model_fields(Film) film_name_set = set([field[0] for field in film_fields]) assert len(film_fields) == len(film_name_set) + + +def test_camelize(): + assert camelize({}) == {} + assert camelize("value_a") == "value_a" + assert camelize({"value_a": "value_b"}) == {"valueA": "value_b"} + assert camelize({"value_a": ["value_b"]}) == {"valueA": ["value_b"]} + assert camelize({"value_a": ["value_b"]}) == {"valueA": ["value_b"]} + assert camelize({"nested_field": {"value_a": ["error"], "value_b": ["error"]}}) == { + "nestedField": {"valueA": ["error"], "valueB": ["error"]} + } + assert camelize({"value_a": gettext_lazy("value_b")}) == {"valueA": "value_b"} + assert camelize({"value_a": [gettext_lazy("value_b")]}) == {"valueA": ["value_b"]} + assert camelize(gettext_lazy("value_a")) == "value_a" + assert camelize({gettext_lazy("value_a"): gettext_lazy("value_b")}) == { + "valueA": "value_b" + } + assert camelize({0: {"field_a": ["errors"]}}) == {0: {"fieldA": ["errors"]}} diff --git a/graphene_django/types.py b/graphene_django/types.py index a1e17b3..ec426f1 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -1,8 +1,10 @@ -import six +import warnings from collections import OrderedDict +import six from django.db.models import Model from django.utils.functional import SimpleLazyObject + import graphene from graphene import Field from graphene.relay import Connection, Node @@ -11,14 +13,24 @@ from graphene.types.utils import yank_fields_from_attrs from .converter import convert_django_field_with_choices from .registry import Registry, get_global_registry -from .utils import DJANGO_FILTER_INSTALLED, get_model_fields, is_valid_django_model - +from .settings import graphene_settings +from .utils import ( + DJANGO_FILTER_INSTALLED, + camelize, + get_model_fields, + is_valid_django_model, +) if six.PY3: from typing import Type -def construct_fields(model, registry, only_fields, exclude_fields): +ALL_FIELDS = "__all__" + + +def construct_fields( + model, registry, only_fields, exclude_fields, convert_choices_to_enum +): _model_fields = get_model_fields(model) fields = OrderedDict() @@ -33,7 +45,18 @@ def construct_fields(model, registry, only_fields, exclude_fields): # in there. Or when we exclude this field in exclude_fields. # Or when there is no back reference. continue - converted = convert_django_field_with_choices(field, registry) + + _convert_choices_to_enum = convert_choices_to_enum + if not isinstance(_convert_choices_to_enum, bool): + # then `convert_choices_to_enum` is a list of field names to convert + if name in _convert_choices_to_enum: + _convert_choices_to_enum = True + else: + _convert_choices_to_enum = False + + converted = convert_django_field_with_choices( + field, registry, convert_choices_to_enum=_convert_choices_to_enum + ) fields[name] = converted return fields @@ -55,14 +78,17 @@ class DjangoObjectType(ObjectType): model=None, registry=None, skip_registry=False, - only_fields=(), - exclude_fields=(), + only_fields=(), # deprecated in favour of `fields` + fields=(), + exclude_fields=(), # deprecated in favour of `exclude` + exclude=(), filter_fields=None, filterset_class=None, connection=None, connection_class=None, use_connection=None, interfaces=(), + convert_choices_to_enum=True, _meta=None, **options ): @@ -89,8 +115,49 @@ class DjangoObjectType(ObjectType): ) ) + assert not (fields and exclude), ( + "Cannot set both 'fields' and 'exclude' options on " + "DjangoObjectType {class_name}.".format(class_name=cls.__name__) + ) + + # Alias only_fields -> fields + if only_fields and fields: + raise Exception("Can't set both only_fields and fields") + if only_fields: + warnings.warn( + "Defining `only_fields` is deprecated in favour of `fields`.", + PendingDeprecationWarning, + stacklevel=2, + ) + fields = only_fields + if fields and fields != ALL_FIELDS and not isinstance(fields, (list, tuple)): + raise TypeError( + 'The `fields` option must be a list or tuple or "__all__". ' + "Got %s." % type(fields).__name__ + ) + + if fields == ALL_FIELDS: + fields = None + + # Alias exclude_fields -> exclude + if exclude_fields and exclude: + raise Exception("Can't set both exclude_fields and exclude") + if exclude_fields: + warnings.warn( + "Defining `exclude_fields` is deprecated in favour of `exclude`.", + PendingDeprecationWarning, + stacklevel=2, + ) + exclude = exclude_fields + if exclude and not isinstance(exclude, (list, tuple)): + raise TypeError( + "The `exclude` option must be a list or tuple. Got %s." + % type(exclude).__name__ + ) + django_fields = yank_fields_from_attrs( - construct_fields(model, registry, only_fields, exclude_fields), _as=Field + construct_fields(model, registry, fields, exclude, convert_choices_to_enum), + _as=Field, ) if use_connection is None and interfaces: @@ -165,3 +232,8 @@ class DjangoObjectType(ObjectType): class ErrorType(ObjectType): field = graphene.String(required=True) messages = graphene.List(graphene.NonNull(graphene.String), required=True) + + @classmethod + def from_errors(cls, errors): + data = camelize(errors) if graphene_settings.CAMELCASE_ERRORS else errors + return [cls(field=key, messages=value) for key, value in data.items()] diff --git a/graphene_django/utils/__init__.py b/graphene_django/utils/__init__.py index f9c388d..9d8658b 100644 --- a/graphene_django/utils/__init__.py +++ b/graphene_django/utils/__init__.py @@ -1,18 +1,20 @@ +from .testing import GraphQLTestCase from .utils import ( DJANGO_FILTER_INSTALLED, - get_reverse_fields, - maybe_queryset, + camelize, get_model_fields, - is_valid_django_model, + get_reverse_fields, import_single_dispatch, + is_valid_django_model, + maybe_queryset, ) -from .testing import GraphQLTestCase __all__ = [ "DJANGO_FILTER_INSTALLED", "get_reverse_fields", "maybe_queryset", "get_model_fields", + "camelize", "is_valid_django_model", "import_single_dispatch", "GraphQLTestCase", diff --git a/graphene_django/utils/testing.py b/graphene_django/utils/testing.py index db3e9f4..0fdac7e 100644 --- a/graphene_django/utils/testing.py +++ b/graphene_django/utils/testing.py @@ -54,8 +54,8 @@ class GraphQLTestCase(TestCase): the call was fine. :resp HttpResponse: Response """ - content = json.loads(resp.content) self.assertEqual(resp.status_code, 200) + content = json.loads(resp.content) self.assertNotIn("errors", list(content.keys())) def assertResponseHasErrors(self, resp): diff --git a/graphene_django/utils/utils.py b/graphene_django/utils/utils.py index b8aaba0..47c0c37 100644 --- a/graphene_django/utils/utils.py +++ b/graphene_django/utils/utils.py @@ -2,7 +2,11 @@ import inspect from django.db import models from django.db.models.manager import Manager +from django.utils import six +from django.utils.encoding import force_text +from django.utils.functional import Promise +from graphene.utils.str_converters import to_camel_case try: import django_filters # noqa @@ -12,6 +16,28 @@ except ImportError: DJANGO_FILTER_INSTALLED = False +def isiterable(value): + try: + iter(value) + except TypeError: + return False + return True + + +def _camelize_django_str(s): + if isinstance(s, Promise): + s = force_text(s) + return to_camel_case(s) if isinstance(s, six.string_types) else s + + +def camelize(data): + if isinstance(data, dict): + return {_camelize_django_str(k): camelize(v) for k, v in data.items()} + if isiterable(data) and not isinstance(data, (six.string_types, Promise)): + return [camelize(d) for d in data] + return data + + def get_reverse_fields(model, local_field_names): for name, attr in model.__dict__.items(): # Don't duplicate any local fields diff --git a/setup.cfg b/setup.cfg index 546ad67..def0b67 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,11 +5,41 @@ test=pytest universal=1 [flake8] -exclude = setup.py,docs/*,examples/*,tests,graphene_django/debug/sql/* +exclude = docs,graphene_django/debug/sql/*,migrations max-line-length = 120 +select = + # Dictionary key repeated + F601, + # Ensure use of ==/!= to compare with str, bytes and int literals + F632, + # Redefinition of unused name + F811, + # Using an undefined variable + F821, + # Defining an undefined variable in __all__ + F822, + # Using a variable before it is assigned + F823, + # Duplicate argument in function declaration + F831, + # Black would format this line + BLK, + # Do not use bare except + B001, + # Don't allow ++n. You probably meant n += 1 + B002, + # Do not use mutable structures for argument defaults + B006, + # Do not perform calls in argument defaults + B008 [coverage:run] omit = */tests/* [isort] known_first_party=graphene,graphene_django +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 diff --git a/setup.py b/setup.py index e622a71..bc7dcd3 100644 --- a/setup.py +++ b/setup.py @@ -28,6 +28,8 @@ tests_require = [ dev_requires = [ "black==19.3b0", "flake8==3.7.7", + "flake8-black==0.1.0", + "flake8-bugbear==19.3.0", ] + tests_require setup( @@ -64,7 +66,11 @@ setup( setup_requires=["pytest-runner"], tests_require=tests_require, rest_framework_require=rest_framework_require, - extras_require={"test": tests_require, "rest_framework": rest_framework_require, "dev": dev_requires}, + extras_require={ + "test": tests_require, + "rest_framework": rest_framework_require, + "dev": dev_requires, + }, include_package_data=True, zip_safe=False, platforms="any", diff --git a/tox.ini b/tox.ini index 58f283a..a1b599a 100644 --- a/tox.ini +++ b/tox.ini @@ -28,12 +28,12 @@ commands = {posargs:py.test --cov=graphene_django graphene_django examples} [testenv:black] basepython = python3.7 -deps = black +deps = -e.[dev] commands = - black --exclude "/migrations/" graphene_django examples --check + black --exclude "/migrations/" graphene_django examples setup.py --check [testenv:flake8] basepython = python3.7 -deps = flake8 +deps = -e.[dev] commands = flake8 graphene_django examples