diff --git a/.github/stale.yml b/.github/stale.yml index dab9fb3..d066ca6 100644 --- a/.github/stale.yml +++ b/.github/stale.yml @@ -1,7 +1,7 @@ # Number of days of inactivity before an issue becomes stale -daysUntilStale: 90 +daysUntilStale: 120 # Number of days of inactivity before a stale issue is closed -daysUntilClose: 14 +daysUntilClose: 30 # Issues with these labels will never be considered stale exemptLabels: - pinned diff --git a/docs/introspection.rst b/docs/introspection.rst index dea55bd..2097c30 100644 --- a/docs/introspection.rst +++ b/docs/introspection.rst @@ -29,6 +29,20 @@ you're ready to use Relay with Graphene GraphQL implementation. The schema file is sorted to create a reproducible canonical representation. +GraphQL SDL Representation +-------------------------- + +The schema can also be exported as a GraphQL SDL file by changing the file +extension : + +.. code:: bash + + ./manage.py graphql_schema --schema tutorial.quickstart.schema --out schema.graphql + +When exporting the schema as a ``.graphql`` file the ``--indent`` option is +ignored. + + Advanced Usage -------------- diff --git a/docs/settings.rst b/docs/settings.rst index 4776ce0..5a7e4c9 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -140,3 +140,33 @@ Default: ``False`` # 'messages': ['This field is required.'], # } # ] + + +``DJANGO_CHOICE_FIELD_ENUM_V3_NAMING`` +-------------------------------------- + +Set to ``True`` to use the new naming format for the auto generated Enum types from Django choice fields. The new format looks like this: ``{app_label}{object_name}{field_name}Choices`` + +Default: ``False`` + + +``DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME`` +-------------------------------------- + +Define the path of a function that takes the Django choice field and returns a string to completely customise the naming for the Enum type. + +If set to a function then the ``DJANGO_CHOICE_FIELD_ENUM_V3_NAMING`` setting is ignored. + +Default: ``None`` + +.. code:: python + + # myapp.utils + def enum_naming(field): + if isinstance(field.model, User): + return f"CustomUserEnum{field.name.title()}" + return f"CustomEnum{field.name.title()}" + + GRAPHENE = { + 'DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME': "myapp.utils.enum_naming" + } diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index dbdc201..6574745 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.8.2" +__version__ = "2.9.0" __all__ = ["__version__", "DjangoObjectType", "DjangoConnectionField"] diff --git a/graphene_django/converter.py b/graphene_django/converter.py index 8b93d17..bd8f79d 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -1,6 +1,7 @@ from collections import OrderedDict from django.db import models from django.utils.encoding import force_str +from django.utils.module_loading import import_string from graphene import ( ID, @@ -22,6 +23,7 @@ from graphene.types.json import JSONString from graphene.utils.str_converters import to_camel_case, to_const from graphql import assert_valid_name +from .settings import graphene_settings from .compat import ArrayField, HStoreField, JSONField, RangeField from .fields import DjangoListField, DjangoConnectionField from .utils import import_single_dispatch @@ -68,6 +70,31 @@ def convert_choices_to_named_enum_with_descriptions(name, choices): return Enum(name, list(named_choices), type=EnumWithDescriptionsType) +def generate_enum_name(django_model_meta, field): + if graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME: + # Try and import custom function + custom_func = import_string( + graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME + ) + name = custom_func(field) + elif graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING is True: + name = "{app_label}{object_name}{field_name}Choices".format( + app_label=to_camel_case(django_model_meta.app_label.title()), + object_name=django_model_meta.object_name, + field_name=to_camel_case(field.name.title()), + ) + else: + name = to_camel_case("{}_{}".format(django_model_meta.object_name, field.name)) + return name + + +def convert_choice_field_to_enum(field, name=None): + if name is None: + name = generate_enum_name(field.model._meta, field) + choices = field.choices + return convert_choices_to_named_enum_with_descriptions(name, choices) + + def convert_django_field_with_choices( field, registry=None, convert_choices_to_enum=True ): @@ -77,9 +104,7 @@ def convert_django_field_with_choices( return converted choices = getattr(field, "choices", None) if choices and convert_choices_to_enum: - meta = field.model._meta - name = to_camel_case("{}_{}".format(meta.object_name, field.name)) - enum = convert_choices_to_named_enum_with_descriptions(name, choices) + enum = convert_choice_field_to_enum(field) required = not (field.blank or field.null) converted = enum(description=field.help_text, required=required) else: diff --git a/graphene_django/fields.py b/graphene_django/fields.py index 47b44f6..fb6b98a 100644 --- a/graphene_django/fields.py +++ b/graphene_django/fields.py @@ -1,5 +1,6 @@ from functools import partial +import six from django.db.models.query import QuerySet from graphql_relay.connection.arrayconnection import connection_from_list_slice from promise import Promise @@ -19,19 +20,23 @@ class DjangoListField(Field): if isinstance(_type, NonNull): _type = _type.of_type - assert issubclass( - _type, DjangoObjectType - ), "DjangoListField only accepts DjangoObjectType types" - # Django would never return a Set of None vvvvvvv super(DjangoListField, self).__init__(List(NonNull(_type)), *args, **kwargs) + assert issubclass( + self._underlying_type, DjangoObjectType + ), "DjangoListField only accepts DjangoObjectType types" + + @property + def _underlying_type(self): + _type = self._type + while hasattr(_type, "of_type"): + _type = _type.of_type + return _type + @property def model(self): - _type = self.type.of_type - if isinstance(_type, NonNull): - _type = _type.of_type - return _type._meta.model + return self._underlying_type._meta.model @staticmethod def list_resolver(django_object_type, resolver, root, info, **args): diff --git a/graphene_django/management/commands/graphql_schema.py b/graphene_django/management/commands/graphql_schema.py index 1e8baf6..751a385 100644 --- a/graphene_django/management/commands/graphql_schema.py +++ b/graphene_django/management/commands/graphql_schema.py @@ -1,3 +1,4 @@ +import os import importlib import json import functools @@ -5,6 +6,7 @@ import functools from django.core.management.base import BaseCommand, CommandError from django.utils import autoreload +from graphql import print_schema from graphene_django.settings import graphene_settings @@ -44,24 +46,40 @@ class CommandArguments(BaseCommand): class Command(CommandArguments): - help = "Dump Graphene schema JSON to file" + help = "Dump Graphene schema as a JSON or GraphQL file" can_import_settings = True - def save_file(self, out, schema_dict, indent): + def save_json_file(self, out, schema_dict, indent): with open(out, "w") as outfile: json.dump(schema_dict, outfile, indent=indent, sort_keys=True) + def save_graphql_file(self, out, schema): + with open(out, "w") as outfile: + outfile.write(print_schema(schema)) + def get_schema(self, schema, out, indent): schema_dict = {"data": schema.introspect()} if out == "-": self.stdout.write(json.dumps(schema_dict, indent=indent, sort_keys=True)) else: - self.save_file(out, schema_dict, indent) + # Determine format + _, file_extension = os.path.splitext(out) + + if file_extension == ".graphql": + self.save_graphql_file(out, schema) + elif file_extension == ".json": + self.save_json_file(out, schema_dict, indent) + else: + raise CommandError( + 'Unrecognised file format "{}"'.format(file_extension) + ) style = getattr(self, "style", None) success = getattr(style, "SUCCESS", lambda x: x) - self.stdout.write(success("Successfully dumped GraphQL schema to %s" % out)) + self.stdout.write( + success("Successfully dumped GraphQL schema to {}".format(out)) + ) def handle(self, *args, **options): options_schema = options.get("schema") diff --git a/graphene_django/settings.py b/graphene_django/settings.py index 05eae80..fe8a9ff 100644 --- a/graphene_django/settings.py +++ b/graphene_django/settings.py @@ -36,7 +36,10 @@ DEFAULTS = { # Max items returned in ConnectionFields / FilterConnectionFields "RELAY_CONNECTION_MAX_LIMIT": 100, "CAMELCASE_ERRORS": False, - "SOURCE": None + "SOURCE": None, + # Set to True to enable v3 naming convention for choice field Enum's + "DJANGO_CHOICE_FIELD_ENUM_V3_NAMING": False, + "DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME": None, } if settings.DEBUG: diff --git a/graphene_django/templates/graphene/base.html b/graphene_django/templates/graphene/base.html index 5a29b32..baee34d 100755 --- a/graphene_django/templates/graphene/base.html +++ b/graphene_django/templates/graphene/base.html @@ -14,11 +14,11 @@ } - - - - - + + + + + {% block title %}{% endblock %} diff --git a/graphene_django/tests/test_command.py b/graphene_django/tests/test_command.py index dbabafa..8b0a8e6 100644 --- a/graphene_django/tests/test_command.py +++ b/graphene_django/tests/test_command.py @@ -1,17 +1,21 @@ +from textwrap import dedent + from django.core import management -from mock import patch, mock_open +from mock import mock_open, patch from six import StringIO +from graphene import ObjectType, Schema, String -@patch("graphene_django.management.commands.graphql_schema.Command.save_file") -def test_generate_file_on_call_graphql_schema(savefile_mock, settings): + +@patch("graphene_django.management.commands.graphql_schema.Command.save_json_file") +def test_generate_json_file_on_call_graphql_schema(savefile_mock, settings): out = StringIO() management.call_command("graphql_schema", schema="", stdout=out) assert "Successfully dumped GraphQL schema to schema.json" in out.getvalue() @patch("json.dump") -def test_files_are_canonical(dump_mock): +def test_json_files_are_canonical(dump_mock): open_mock = mock_open() with patch("graphene_django.management.commands.graphql_schema.open", open_mock): management.call_command("graphql_schema", schema="") @@ -25,3 +29,34 @@ def test_files_are_canonical(dump_mock): assert ( dump_mock.call_args[1]["indent"] > 0 ), "output should be pretty-printed by default" + + +def test_generate_graphql_file_on_call_graphql_schema(): + class Query(ObjectType): + hi = String() + + mock_schema = Schema(query=Query) + + open_mock = mock_open() + with patch("graphene_django.management.commands.graphql_schema.open", open_mock): + management.call_command( + "graphql_schema", schema=mock_schema, out="schema.graphql" + ) + + open_mock.assert_called_once() + + handle = open_mock() + assert handle.write.called_once() + + 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 fe57ed2..f1d1b9b 100644 --- a/graphene_django/tests/test_converter.py +++ b/graphene_django/tests/test_converter.py @@ -1,4 +1,5 @@ import pytest +from collections import namedtuple from django.db import models from django.utils.translation import gettext_lazy as _ from graphene import NonNull @@ -10,9 +11,14 @@ from graphene.types.datetime import DateTime, Date, Time from graphene.types.json import JSONString from ..compat import JSONField, ArrayField, HStoreField, RangeField, MissingType -from ..converter import convert_django_field, convert_django_field_with_choices +from ..converter import ( + convert_django_field, + convert_django_field_with_choices, + generate_enum_name, +) from ..registry import Registry from ..types import DjangoObjectType +from ..settings import graphene_settings from .models import Article, Film, FilmDetails, Reporter @@ -325,3 +331,25 @@ def test_should_postgres_range_convert_list(): assert isinstance(field.type, graphene.NonNull) assert isinstance(field.type.of_type, graphene.List) assert field.type.of_type.of_type == graphene.Int + + +def test_generate_enum_name(): + MockDjangoModelMeta = namedtuple("DjangoMeta", ["app_label", "object_name"]) + graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = True + + # Simple case + field = graphene.Field(graphene.String, name="type") + model_meta = MockDjangoModelMeta(app_label="users", object_name="User") + assert generate_enum_name(model_meta, field) == "UsersUserTypeChoices" + + # More complicated multiple work case + field = graphene.Field(graphene.String, name="fizz_buzz") + model_meta = MockDjangoModelMeta( + app_label="some_long_app_name", object_name="SomeObject" + ) + assert ( + 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 f6abf00..8ea1901 100644 --- a/graphene_django/tests/test_fields.py +++ b/graphene_django/tests/test_fields.py @@ -19,6 +19,12 @@ class TestDjangoListField: with pytest.raises(AssertionError): list_field = DjangoListField(TestType) + def test_only_import_paths(self): + list_field = DjangoListField("graphene_django.tests.schema.Human") + from .schema import Human + + assert list_field._type.of_type.of_type is Human + def test_non_null_type(self): class Reporter(DjangoObjectType): class Meta: diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index c32f46c..888521f 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -9,7 +9,9 @@ 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 @@ -386,6 +388,10 @@ def test_django_objecttype_exclude_fields_exist_on_model(): assert len(record) == 0 +def custom_enum_name(field): + return "CustomEnum{}".format(field.name.title()) + + class TestDjangoObjectType: @pytest.fixture def PetModel(self): @@ -492,3 +498,78 @@ class TestDjangoObjectType: } """ ) + + def test_django_objecttype_convert_choices_enum_naming_collisions(self, PetModel): + graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = True + + class PetModelKind(DjangoObjectType): + class Meta: + model = PetModel + fields = ["id", "kind"] + + class Query(ObjectType): + pet = Field(PetModelKind) + + schema = Schema(query=Query) + + assert str(schema) == dedent( + """\ + schema { + query: Query + } + + type PetModelKind { + id: ID! + kind: TestsPetModelKindChoices! + } + + type Query { + pet: PetModelKind + } + + enum TestsPetModelKindChoices { + CAT + DOG + } + """ + ) + graphene_settings.DJANGO_CHOICE_FIELD_ENUM_V3_NAMING = False + + def test_django_objecttype_choices_custom_enum_name(self, PetModel): + graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME = ( + "graphene_django.tests.test_types.custom_enum_name" + ) + + class PetModelKind(DjangoObjectType): + class Meta: + model = PetModel + fields = ["id", "kind"] + + class Query(ObjectType): + pet = Field(PetModelKind) + + schema = Schema(query=Query) + + assert str(schema) == dedent( + """\ + schema { + query: Query + } + + enum CustomEnumKind { + CAT + DOG + } + + type PetModelKind { + id: ID! + kind: CustomEnumKind! + } + + type Query { + pet: PetModelKind + } + """ + ) + + graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME = None diff --git a/setup.py b/setup.py index a3d0b74..560549a 100644 --- a/setup.py +++ b/setup.py @@ -26,10 +26,10 @@ tests_require = [ dev_requires = [ - "black==19.3b0", - "flake8==3.7.7", - "flake8-black==0.1.0", - "flake8-bugbear==19.3.0", + "black==19.10b0", + "flake8==3.7.9", + "flake8-black==0.1.1", + "flake8-bugbear==20.1.4", ] + tests_require setup(