diff --git a/examples/starwars_newenum/__init__.py b/examples/starwars_newenum/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/starwars_newenum/data.py b/examples/starwars_newenum/data.py new file mode 100644 index 000000000..ed65ee85f --- /dev/null +++ b/examples/starwars_newenum/data.py @@ -0,0 +1,97 @@ +human_data = {} +droid_data = {} + + +def setup(): + from .schema import Human, Droid, Episode + + global human_data, droid_data + luke = Human( + id="1000", + name="Luke Skywalker", + friends=["1002", "1003", "2000", "2001"], + appears_in=[Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI], + home_planet="Tatooine", + ) + + vader = Human( + id="1001", + name="Darth Vader", + friends=["1004"], + appears_in=[Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI], + home_planet="Tatooine", + ) + + han = Human( + id="1002", + name="Han Solo", + friends=["1000", "1003", "2001"], + appears_in=[Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI], + home_planet=None, + ) + + leia = Human( + id="1003", + name="Leia Organa", + friends=["1000", "1002", "2000", "2001"], + appears_in=[Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI], + home_planet="Alderaan", + ) + + tarkin = Human( + id="1004", + name="Wilhuff Tarkin", + friends=["1001"], + appears_in=[Episode.NEWHOPE], + home_planet=None, + ) + + human_data = { + "1000": luke, + "1001": vader, + "1002": han, + "1003": leia, + "1004": tarkin, + } + + c3po = Droid( + id="2000", + name="C-3PO", + friends=["1000", "1002", "1003", "2001"], + appears_in=[Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI], + primary_function="Protocol", + ) + + r2d2 = Droid( + id="2001", + name="R2-D2", + friends=["1000", "1002", "1003"], + appears_in=[Episode.NEWHOPE, Episode.EMPIRE, Episode.JEDI], + primary_function="Astromech", + ) + + droid_data = {"2000": c3po, "2001": r2d2} + + +def get_character(id): + return human_data.get(id) or droid_data.get(id) + + +def get_friends(character): + return map(get_character, character.friends) + + +def get_hero(episode): + from .schema import Episode + + if episode == Episode.EMPIRE: + return human_data["1000"] + return droid_data["2001"] + + +def get_human(id): + return human_data.get(id) + + +def get_droid(id): + return droid_data.get(id) diff --git a/examples/starwars_newenum/schema.py b/examples/starwars_newenum/schema.py new file mode 100644 index 000000000..6edb312b5 --- /dev/null +++ b/examples/starwars_newenum/schema.py @@ -0,0 +1,57 @@ +from enum import Enum + +import graphene + +from .data import get_character, get_droid, get_hero, get_human + + +class Episode(Enum): + NEWHOPE = 4 + EMPIRE = 5 + JEDI = 6 + + +GrapheneEpisode = graphene.Enum.from_enum(Episode, legacy_enum_resolver=False) + + +class Character(graphene.Interface): + id = graphene.ID() + name = graphene.String() + friends = graphene.List(lambda: Character) + appears_in = graphene.List(GrapheneEpisode) + + def resolve_friends(self, info): + # The character friends is a list of strings + return [get_character(f) for f in self.friends] + + +class Human(graphene.ObjectType): + class Meta: + interfaces = (Character,) + + home_planet = graphene.String() + + +class Droid(graphene.ObjectType): + class Meta: + interfaces = (Character,) + + primary_function = graphene.String() + + +class Query(graphene.ObjectType): + hero = graphene.Field(Character, episode=GrapheneEpisode()) + human = graphene.Field(Human, id=graphene.String()) + droid = graphene.Field(Droid, id=graphene.String()) + + def resolve_hero(self, info, episode=None): + return get_hero(episode) + + def resolve_human(self, info, id): + return get_human(id) + + def resolve_droid(self, info, id): + return get_droid(id) + + +schema = graphene.Schema(query=Query) diff --git a/examples/starwars_newenum/tests/__init__.py b/examples/starwars_newenum/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/starwars_newenum/tests/snapshots/__init__.py b/examples/starwars_newenum/tests/snapshots/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/starwars_newenum/tests/snapshots/snap_test_query.py b/examples/starwars_newenum/tests/snapshots/snap_test_query.py new file mode 100644 index 000000000..b4f05bdb8 --- /dev/null +++ b/examples/starwars_newenum/tests/snapshots/snap_test_query.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +# snapshottest: v1 - https://goo.gl/zC4yUc +from __future__ import unicode_literals + +from snapshottest import Snapshot + +snapshots = Snapshot() + +snapshots["test_hero_name_query 1"] = {"data": {"hero": {"name": "R2-D2"}}} + +snapshots["test_hero_name_and_friends_query 1"] = { + "data": { + "hero": { + "id": "2001", + "name": "R2-D2", + "friends": [ + {"name": "Luke Skywalker"}, + {"name": "Han Solo"}, + {"name": "Leia Organa"}, + ], + } + } +} + +snapshots["test_nested_query 1"] = { + "data": { + "hero": { + "name": "R2-D2", + "friends": [ + { + "name": "Luke Skywalker", + "appearsIn": ["NEWHOPE", "EMPIRE", "JEDI"], + "friends": [ + {"name": "Han Solo"}, + {"name": "Leia Organa"}, + {"name": "C-3PO"}, + {"name": "R2-D2"}, + ], + }, + { + "name": "Han Solo", + "appearsIn": ["NEWHOPE", "EMPIRE", "JEDI"], + "friends": [ + {"name": "Luke Skywalker"}, + {"name": "Leia Organa"}, + {"name": "R2-D2"}, + ], + }, + { + "name": "Leia Organa", + "appearsIn": ["NEWHOPE", "EMPIRE", "JEDI"], + "friends": [ + {"name": "Luke Skywalker"}, + {"name": "Han Solo"}, + {"name": "C-3PO"}, + {"name": "R2-D2"}, + ], + }, + ], + } + } +} + +snapshots["test_fetch_luke_query 1"] = {"data": {"human": {"name": "Luke Skywalker"}}} + +snapshots["test_fetch_some_id_query 1"] = { + "data": {"human": {"name": "Luke Skywalker"}} +} + +snapshots["test_fetch_some_id_query2 1"] = {"data": {"human": {"name": "Han Solo"}}} + +snapshots["test_invalid_id_query 1"] = {"data": {"human": None}} + +snapshots["test_fetch_luke_aliased 1"] = {"data": {"luke": {"name": "Luke Skywalker"}}} + +snapshots["test_fetch_luke_and_leia_aliased 1"] = { + "data": {"luke": {"name": "Luke Skywalker"}, "leia": {"name": "Leia Organa"}} +} + +snapshots["test_duplicate_fields 1"] = { + "data": { + "luke": {"name": "Luke Skywalker", "homePlanet": "Tatooine"}, + "leia": {"name": "Leia Organa", "homePlanet": "Alderaan"}, + } +} + +snapshots["test_use_fragment 1"] = { + "data": { + "luke": {"name": "Luke Skywalker", "homePlanet": "Tatooine"}, + "leia": {"name": "Leia Organa", "homePlanet": "Alderaan"}, + } +} + +snapshots["test_check_type_of_r2 1"] = { + "data": {"hero": {"__typename": "Droid", "name": "R2-D2"}} +} + +snapshots["test_check_type_of_luke 1"] = { + "data": {"hero": {"__typename": "Human", "name": "Luke Skywalker"}} +} diff --git a/examples/starwars_newenum/tests/test_query.py b/examples/starwars_newenum/tests/test_query.py new file mode 100644 index 000000000..88934b0ed --- /dev/null +++ b/examples/starwars_newenum/tests/test_query.py @@ -0,0 +1,182 @@ +from graphene.test import Client + +from ..data import setup +from ..schema import schema + +setup() + +client = Client(schema) + + +def test_hero_name_query(snapshot): + query = """ + query HeroNameQuery { + hero { + name + } + } + """ + snapshot.assert_match(client.execute(query)) + + +def test_hero_name_and_friends_query(snapshot): + query = """ + query HeroNameAndFriendsQuery { + hero { + id + name + friends { + name + } + } + } + """ + snapshot.assert_match(client.execute(query)) + + +def test_nested_query(snapshot): + query = """ + query NestedQuery { + hero { + name + friends { + name + appearsIn + friends { + name + } + } + } + } + """ + snapshot.assert_match(client.execute(query)) + + +def test_fetch_luke_query(snapshot): + query = """ + query FetchLukeQuery { + human(id: "1000") { + name + } + } + """ + snapshot.assert_match(client.execute(query)) + + +def test_fetch_some_id_query(snapshot): + query = """ + query FetchSomeIDQuery($someId: String!) { + human(id: $someId) { + name + } + } + """ + params = {"someId": "1000"} + snapshot.assert_match(client.execute(query, variables=params)) + + +def test_fetch_some_id_query2(snapshot): + query = """ + query FetchSomeIDQuery($someId: String!) { + human(id: $someId) { + name + } + } + """ + params = {"someId": "1002"} + snapshot.assert_match(client.execute(query, variables=params)) + + +def test_invalid_id_query(snapshot): + query = """ + query humanQuery($id: String!) { + human(id: $id) { + name + } + } + """ + params = {"id": "not a valid id"} + snapshot.assert_match(client.execute(query, variables=params)) + + +def test_fetch_luke_aliased(snapshot): + query = """ + query FetchLukeAliased { + luke: human(id: "1000") { + name + } + } + """ + snapshot.assert_match(client.execute(query)) + + +def test_fetch_luke_and_leia_aliased(snapshot): + query = """ + query FetchLukeAndLeiaAliased { + luke: human(id: "1000") { + name + } + leia: human(id: "1003") { + name + } + } + """ + snapshot.assert_match(client.execute(query)) + + +def test_duplicate_fields(snapshot): + query = """ + query DuplicateFields { + luke: human(id: "1000") { + name + homePlanet + } + leia: human(id: "1003") { + name + homePlanet + } + } + """ + snapshot.assert_match(client.execute(query)) + + +def test_use_fragment(snapshot): + query = """ + query UseFragment { + luke: human(id: "1000") { + ...HumanFragment + } + leia: human(id: "1003") { + ...HumanFragment + } + } + fragment HumanFragment on Human { + name + homePlanet + } + """ + snapshot.assert_match(client.execute(query)) + + +def test_check_type_of_r2(snapshot): + query = """ + query CheckTypeOfR2 { + hero { + __typename + name + } + } + """ + snapshot.assert_match(client.execute(query)) + + +def test_check_type_of_luke(snapshot): + query = """ + query CheckTypeOfLuke { + hero(episode: EMPIRE) { + __typename + name + } + } + """ + snapshot.assert_match(client.execute(query)) diff --git a/examples/starwars_newenum/tests/test_schema.py b/examples/starwars_newenum/tests/test_schema.py new file mode 100644 index 000000000..e69de29bb diff --git a/graphene/types/definitions.py b/graphene/types/definitions.py index a914008c5..6a34c97fb 100644 --- a/graphene/types/definitions.py +++ b/graphene/types/definitions.py @@ -36,7 +36,11 @@ class GrapheneScalarType(GrapheneGraphQLType, GraphQLScalarType): class GrapheneEnumType(GrapheneGraphQLType, GraphQLEnumType): - pass + def serialize(self, value): + if not self.graphene_type._meta.legacy_enum_resolver: + if value in self.graphene_type._meta.enum: + return value.name + return super(GrapheneEnumType, self).serialize(value) class GrapheneInputObjectType(GrapheneGraphQLType, GraphQLInputObjectType): diff --git a/graphene/types/enum.py b/graphene/types/enum.py index 6e6bab8f3..d31b534ae 100644 --- a/graphene/types/enum.py +++ b/graphene/types/enum.py @@ -23,13 +23,29 @@ class EnumOptions(BaseOptions): deprecation_reason = None +def _filter_magic_members(classdict): + def is_special(name): + # We also remove the Meta attribute from the class to not collide + # with the enum values. + if name == "Meta": + return True + return name[:2] == name[-2:] == "__" + + return OrderedDict((k, v) for k, v in classdict.items() if not is_special(k)) + + class EnumMeta(SubclassWithMeta_Meta): def __new__(cls, name, bases, classdict, **options): - enum_members = OrderedDict(classdict, __eq__=eq_enum) - # We remove the Meta attribute from the class to not collide - # with the enum values. - enum_members.pop("Meta", None) - enum = PyEnum(cls.__name__, enum_members) + meta_class = classdict.get("Meta") + if meta_class is None or not hasattr(meta_class, "legacy_enum_resolver"): + is_legacy = True + else: + is_legacy = meta_class.legacy_enum_resolver + + enum_members = _filter_magic_members(classdict) + if is_legacy: + enum_members["__eq__"] = eq_enum + enum = PyEnum(name, enum_members) return SubclassWithMeta_Meta.__new__( cls, name, bases, OrderedDict(classdict, __enum__=enum), **options ) @@ -48,14 +64,16 @@ def __call__(cls, *args, **kwargs): # noqa: N805 description = kwargs.pop("description", None) return cls.from_enum(PyEnum(*args, **kwargs), description=description) return super(EnumMeta, cls).__call__(*args, **kwargs) - # return cls._meta.enum(*args, **kwargs) - def from_enum(cls, enum, description=None, deprecation_reason=None): # noqa: N805 + def from_enum( + cls, enum, description=None, deprecation_reason=None, legacy_enum_resolver=True + ): # noqa: N805 description = description or enum.__doc__ meta_dict = { "enum": enum, "description": description, "deprecation_reason": deprecation_reason, + "legacy_enum_resolver": legacy_enum_resolver, } meta_class = type("Meta", (object,), meta_dict) return type(meta_class.enum.__name__, (Enum,), {"Meta": meta_class}) @@ -68,6 +86,7 @@ def __init_subclass_with_meta__(cls, enum=None, _meta=None, **options): _meta = EnumOptions(cls) _meta.enum = enum or cls.__enum__ _meta.deprecation_reason = options.pop("deprecation_reason", None) + _meta.legacy_enum_resolver = options.pop("legacy_enum_resolver", True) for key, value in _meta.enum.__members__.items(): setattr(cls, key, value) diff --git a/graphene/types/tests/test_enum.py b/graphene/types/tests/test_enum.py index 6086f54ce..c11b2d6ad 100644 --- a/graphene/types/tests/test_enum.py +++ b/graphene/types/tests/test_enum.py @@ -183,7 +183,7 @@ class RGB(Enum): assert unmounted_field.type == RGB -def test_enum_can_be_compared(): +def test_legacy_enum_can_be_compared(): class RGB(Enum): RED = 1 GREEN = 2 @@ -194,6 +194,22 @@ class RGB(Enum): assert RGB.BLUE == 3 +def test_new_enum_only_compare_to_enum_instances(): + class RGBBase(PyEnum): + RED = 1 + GREEN = 2 + BLUE = 3 + + RGB = Enum.from_enum(RGBBase, legacy_enum_resolver=False) + + assert RGB.RED == RGBBase.RED + assert RGB.GREEN == RGBBase.GREEN + assert RGB.BLUE == RGBBase.BLUE + assert RGB.RED != 1 + assert RGB.GREEN != 2 + assert RGB.BLUE != 3 + + def test_enum_can_be_initialzied(): class RGB(Enum): RED = 1 diff --git a/graphene/types/tests/test_resolver_enum_arg.py b/graphene/types/tests/test_resolver_enum_arg.py new file mode 100644 index 000000000..c2d8b5967 --- /dev/null +++ b/graphene/types/tests/test_resolver_enum_arg.py @@ -0,0 +1,56 @@ +from enum import Enum as PyEnum + +from ..objecttype import ObjectType +from ..scalars import String +from ..schema import Schema + +from ..enum import Enum + + +class PythonEnum(PyEnum): + P1 = "p1" + P2 = "p2" + + +PythonBaseEnum = Enum.from_enum(PythonEnum, legacy_enum_resolver=False) + + +class Query(ObjectType): + python = String(v=PythonBaseEnum(default_value=PythonBaseEnum.P1)) + + def resolve_python(self, _, v): + return "python" + + +def test_fixture_sane(): + """Check that the fixture enums are built correctly""" + assert PythonBaseEnum.P1.value == "p1" + assert PythonBaseEnum.P2.value == "p2" + + assert PythonBaseEnum.P1 != "p1" + assert PythonBaseEnum.P2 != "p2" + + +def _call_and_get_arg(mocker, resolver_name, query): + resolver = mocker.patch.object(Query, resolver_name, return_value="mocked") + schema = Schema(Query) + + r = schema.execute(query) + assert not r.errors + + assert resolver.call_count == 1 + + return resolver.call_args[1]["v"] + + +def test_resolve_enum_python(mocker): + arg = _call_and_get_arg(mocker, "resolve_python", "{python(v:P2)}") + assert arg is PythonBaseEnum.P2 + assert arg is not PythonBaseEnum.P2.value + assert arg is PythonEnum.P2 + assert arg is not PythonEnum.P2.value + + +def test_resolve_enum_default_value_python(mocker): + param = _call_and_get_arg(mocker, "resolve_python", "{python}") + assert param == PythonBaseEnum.P1 diff --git a/graphene/types/tests/test_resolver_enum_arg_legacy.py b/graphene/types/tests/test_resolver_enum_arg_legacy.py new file mode 100644 index 000000000..5ffdc845c --- /dev/null +++ b/graphene/types/tests/test_resolver_enum_arg_legacy.py @@ -0,0 +1,98 @@ +from enum import Enum as PyEnum + +from ..objecttype import ObjectType +from ..scalars import String +from ..schema import Schema + +from ..enum import Enum + + +class SimpleEnum(Enum): + S1 = "s1" + S2 = "s2" + + +class PythonEnum(PyEnum): + P1 = "p1" + P2 = "p2" + + +PythonBaseEnum = Enum.from_enum(PythonEnum) + +FunctionalEnum = Enum("Functional", [("F1", "f1"), ("F2", "f2")]) + + +class Query(ObjectType): + simple = String(v=SimpleEnum(default_value=SimpleEnum.S1)) + python = String(v=PythonBaseEnum(default_value=PythonBaseEnum.P1)) + functional = String(v=FunctionalEnum(default_value=FunctionalEnum.F1)) + + def resolve_simple(self, _, v): + return "simple" + + def resolve_python(self, _, v): + return "python" + + def resolve_functional(self, _, v): + return "functional" + + +def test_fixture_sane(): + """Check that the fixture enums are built correctly""" + assert SimpleEnum.S1.value == "s1" + assert SimpleEnum.S2.value == "s2" + + assert PythonBaseEnum.P1.value == "p1" + assert PythonBaseEnum.P2.value == "p2" + + assert FunctionalEnum.F1.value == "f1" + assert FunctionalEnum.F2.value == "f2" + + +def _call(schema, query): + r = schema.execute("{simple}") + assert not r.errors + return r.data + + +def _call_and_get_arg(mocker, resolver_name, query): + resolver = mocker.patch.object(Query, resolver_name, return_value="mocked") + schema = Schema(Query) + + r = schema.execute(query) + assert not r.errors + + assert resolver.call_count == 1 + + return resolver.call_args[1]["v"] + + +def test_resolve_simple_enum(mocker): + arg = _call_and_get_arg(mocker, "resolve_simple", "{simple(v:S2)}") + assert arg == SimpleEnum.S2.value + + +def test_resolve_enum_python(mocker): + arg = _call_and_get_arg(mocker, "resolve_python", "{python(v:P2)}") + assert arg == PythonBaseEnum.P2.value + assert arg == PythonEnum.P2.value + + +def test_resolve_enum_functional_api(mocker): + arg = _call_and_get_arg(mocker, "resolve_functional", "{functional(v:F2)}") + assert arg == FunctionalEnum.F2.value + + +def test_resolve_enum_default_value_simple(mocker): + param = _call_and_get_arg(mocker, "resolve_simple", "{simple}") + assert param == SimpleEnum.S1 + + +def test_resolve_enum_default_value_python(mocker): + param = _call_and_get_arg(mocker, "resolve_python", "{python}") + assert param == PythonBaseEnum.P1 + + +def test_resolve_enum_default_value_functional(mocker): + param = _call_and_get_arg(mocker, "resolve_functional", "{functional}") + assert param == FunctionalEnum.F1 diff --git a/graphene/types/tests/test_typemap.py b/graphene/types/tests/test_typemap.py index f713726fc..0d0e07eae 100644 --- a/graphene/types/tests/test_typemap.py +++ b/graphene/types/tests/test_typemap.py @@ -13,6 +13,7 @@ from ..dynamic import Dynamic from ..enum import Enum +from enum import Enum as PyEnum from ..field import Field from ..inputfield import InputField from ..inputobjecttype import InputObjectType @@ -23,7 +24,7 @@ from ..typemap import TypeMap, resolve_type -def test_enum(): +def test_enum_legacy(): class MyEnum(Enum): """Description""" @@ -57,6 +58,44 @@ def deprecation_reason(self): ] +def test_enum_new(): + class MyEnumBase(PyEnum): + """Description""" + + foo = 1 + bar = 2 + + @property + def description(self): + return "Description {}={}".format(self.name, self.value) + + @property + def deprecation_reason(self): + if self == MyEnum.foo: + return "Is deprecated" + + MyEnum = Enum.from_enum(MyEnumBase, legacy_enum_resolver=False) + + typemap = TypeMap([MyEnum]) + assert "MyEnumBase" in typemap + graphql_enum = typemap["MyEnumBase"] + assert isinstance(graphql_enum, GraphQLEnumType) + assert graphql_enum.name == "MyEnumBase" + assert graphql_enum.description == "Description" + values = graphql_enum.values + assert values == [ + GraphQLEnumValue( + name="foo", + value=MyEnumBase.foo, + description="Description foo=1", + deprecation_reason="Is deprecated", + ), + GraphQLEnumValue( + name="bar", value=MyEnumBase.bar, description="Description bar=2" + ), + ] + + def test_objecttype(): class MyObjectType(ObjectType): """Description""" diff --git a/graphene/types/typemap.py b/graphene/types/typemap.py index 9edb85181..3b4b1caf5 100644 --- a/graphene/types/typemap.py +++ b/graphene/types/typemap.py @@ -149,9 +149,14 @@ def construct_enum(self, map, type): if not deprecation_reason and callable(type._meta.deprecation_reason): deprecation_reason = type._meta.deprecation_reason(value) + if type._meta.legacy_enum_resolver: + gql_value = value.value + else: + gql_value = value + values[name] = GraphQLEnumValue( name=name, - value=value.value, + value=gql_value, description=description, deprecation_reason=deprecation_reason, )