Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve auto creation of Graphene Enums. #98

Closed
wants to merge 11 commits into from

Conversation

Cito
Copy link
Member

@Cito Cito commented Dec 1, 2017

This PR is an improvement of PR #78.

The automatically created Graphene Enums are now registered in order to make them reusable. Different SQLAlchemy Enums with the same items are mapped to the same Graphene Enum Types if their names and items match.

When Graphene Enums are automatically created, the type names and the names of the options are mangled to match the GraphQL conventions (CamelCase type names and UPPERCASE options).

@coveralls
Copy link

coveralls commented Dec 1, 2017

Coverage Status

Coverage decreased (-0.6%) to 90.415% when pulling d8c39a5 on Cito:convert_enums into 4827ce2 on graphql-python:master.

@coveralls
Copy link

Coverage Status

Coverage decreased (-0.6%) to 90.415% when pulling baa454d on Cito:convert_enums into 4827ce2 on graphql-python:master.

1 similar comment
@coveralls
Copy link

coveralls commented Dec 1, 2017

Coverage Status

Coverage decreased (-0.6%) to 90.415% when pulling baa454d on Cito:convert_enums into 4827ce2 on graphql-python:master.

@coveralls
Copy link

Coverage Status

Coverage increased (+0.7%) to 91.64% when pulling 76b32ff on Cito:convert_enums into 4827ce2 on graphql-python:master.

@coveralls
Copy link

coveralls commented Dec 1, 2017

Coverage Status

Coverage increased (+0.3%) to 93.224% when pulling 361b44c on Cito:convert_enums into ef1fce2 on graphql-python:master.

@coveralls
Copy link

Coverage Status

Coverage increased (+0.7%) to 91.64% when pulling 76b32ff on Cito:convert_enums into 4827ce2 on graphql-python:master.

1 similar comment
@coveralls
Copy link

Coverage Status

Coverage increased (+0.7%) to 91.64% when pulling 76b32ff on Cito:convert_enums into 4827ce2 on graphql-python:master.

@Fedalto
Copy link

Fedalto commented Dec 8, 2017

I think the enum should not be modified here (CamelCase type names and UPPERCASE options). Because it would be inconsistent with what graphene.Enum does.
Imagine that I have a python MyEnum, and use it in a column with sqlalchemy.Enum(MyEnum) and then I want a mutation that received this value.
I cannot do
value = graphene.Argument(graphene.Enum.from_enum(MyEnum)) because this is generating a different enum than the one based on the sqlalchemy column.

@Cito
Copy link
Member Author

Cito commented Dec 10, 2017

@Fedalto: Thanks for the feedback. We really need discussion, since this is complicated.

First, just to clarify: The CamelCase conversion is done to the enum type name, the UPPERCASE conversion happens to the enum key names. Graphene converts method names, but not type names, because Python has already the right name convention for these. However, databases usually have lower case type names for their enum types. Like in this example:

CREATE TYPE user_mood AS ENUM ('sad', 'ok', 'happy');

In SQLAlchemy you would write this type as

sqlalchemy.Enum('sad', 'ok', 'happy', name='user_mood')

It makes sense to convert this to a Graphene GraphQL Enum Type with he name UserMood and keys SAD, OK and HAPPY because that's what you expect in GraphQL, and on the other hand you don't want to have camelcase type and uppercase value names in the database. Graphene is not in the business of converting database types, so I don't think we can speak of an inconsistency here.

The complexity comes from the fact that we are dealing with 4 different kinds of enums: The actual database enum type (which might be a native type or just simulated with a check constraint), the SQLAlchemy Enum type that reflects it, the Python Enum that might (or might not) be underlying that SQLAlchemy Enum, and finally the graphene Enum GraphQL type.

Note that the SQLAlchemy Enum type must not necessarily be based off a Python Enum. You can define a SQLAlchemy Enum simply like in the example above, without using a Python Enum, or you can use a Python Enum like this:

PyUserMoodType = enum.Enum('UserMood', 'sad ok happy')
SaUserMoodType = sqlalchemy.Enum(PyUserMoodType)

In this case, the type name would be converted to usermood on the database.

My point is, when using graphene_sqlalchemy, we must use the SQLAlchemy Enum type as the source for converting to GraphQL, not the Python Enum type, because we might not even have one and because graphene_sqlalchemy operates one level higher in the abstraction.

The typical example is, like in your case, you have a property user_mood in an SQLAlchemy base class User like this:

UserMood = sqlalchemy.Enum('sad', 'ok', 'happy', name='user_mood')

class User(Base):
    __tablename__ = 'users'

    id = Column(Integer, primary_key=True)
    user_name = Column(String)
    user_mood = Column(UserMood)

class UserType(SQLAlchemyObjectType):
    class Meta:
        model = User

The question is how we can use the automatically created graphene enum type for the user mood in Arguments (like a filter in a query or in a mutation). We can't just redefine it with graphene.Enum because, as you noticed we then would have two different enum types. My solution was using the registry - that already exists in graphene_sqlalchemy - to not only store the types for sqlalchemy object types, but also for sqlalchemy enums. So we could do this:

from graphene_sqlalchemy.registry import registry

users = List(UserType, kind=Argument(registry.get_type_for_enum(UserMood)))

This would use the exact same GraphQL type for the argument as it is used on the column.

Alternatively, if you want to avoid using the registry, you can also do this:

users = List(UserType, kind=Argument(UserType._meta.fields['user_mood'].type))

I know this is a bit ugly (particularly because it is accessing a "private" attribute), but still readable. The expression simply gets the (GraphQL) type of the 'user_mood' field of the UserType GraphQL type.

Sorry for this lengthy explanation, but as I said this matter is complicated because of all the layers of abstraction and "impedence mismatch" on all the layers.

@Fedalto
Copy link

Fedalto commented Dec 11, 2017

@Cito, thanks for the explanation.

Graphene converts method names, but not type names, because Python has already the right name convention for these.

I think I have a problem with this assumption. I'd not assume that a python enum will always have uppercase keys. So, I guess my problem is that this enum would result in lowercase keys:

graphene.Enum.from_enum(PyEnum('UserMood', 'sad ok happy')

but not when coming from a sqlalchemy enum.

What about the key and name changes be a responsibility of graphene.Enum?
That way, the GraphQL enum will always comply with the convention no matter the enum source of truth.

@Cito
Copy link
Member Author

Cito commented Dec 11, 2017

@Fedalto Thanks for the feedback.

It would be nice if we could shift the responsibility for name mangling to Graphene. But we can't do that since Graphene is a lower level interface and therefore does not and should not care. It takes the Enum type names and options exactly like you define them. This allows you to build anything with it. Note that while Graphene does in fact convert field names automatically, you can still override it by explicitly setting names on the fields.

However, Graphene-SQLAlchemy is a higher level interface that seeks to "translate" from SQL to GraphQL in a conventient and automatic fashion. Therefore, in my view, it should - at least by default - also translate the naming conventions, which are lowercase enum options and type names in SQL, but uppercase enum options and camel case type names in GraphQL. That way you get what you normally expect in each of the two worlds instead of forcing an alien naming convention to the other world.

Note that Graphene-SQLAlchemy also converts field names from snake_case to camelCase using the default conversion mechanism in Graphene, but contrary to when you use Graphene manually, you can't change that behavior - since the fields are created automatically, you have no chance to override the name parameter in that automatic process.

Of course, it would be nice if we could make the automatic conversion configurable on the level of the registry, class or field. On the class level, maybe we could use the Meta attribute, like this?

class UserType(SQLAlchemyObjectType):
    class Meta:
        model = User
        translate_field_names = True
        translate_enum_options = False
        translate_type_names = True

@kavink
Copy link

kavink commented Feb 13, 2018

Any thoughts on if this is going to be merged or how should one handle enum's ?

@kavink
Copy link

kavink commented Feb 19, 2018

@Cito @Fedalto @syrusakbary Any thoughts or recommendations on how one should proceed with implementing Enums as Graphene enums ?

@Lelby
Copy link

Lelby commented Feb 26, 2018

I tried to use these auto-created enums with #87, and it fails. Is it supposed to be working though?

I am really looking forward to be able to use graphene-sqlalchemy fully, but with current 'Enums problems' and 'manually-generated mutations' it is a bummer :(

UPD By 'it fails' I mean the following error: ValueError: Expected XXX_SOME_FIELD_XXX to be Argument, but received Field. Try using Argument(XXX_MY_ENUM_NAME_XXX).

@arlobryer
Copy link

Hi, I'd love to know if this can be merged - @Cito, @Fedalto, @syrusakbary, any further thoughts from your perspective?

@Cito
Copy link
Member Author

Cito commented Jun 3, 2018

No further thoughts from my side. Would also love to see graphene-sqlalchemy moving forward.

Copy link
Contributor

@wichert wichert left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm looking at this PR a bit since I am also trying to use Enums with graphene_sqlalchemy. I just submitted #154 which probably conflicts with PR since it makes changes to the same test.

One things I am missing is this PR are some tests that prove it works with enum.Enum / PyEnum based enums as well; they tend to behave subtly differently so it would be good to have some coverage for those as well.

I'm a bit conflicted about the naming discussion: I buy the argument that following GraphQL standards is a good thing, and that graphene_sqlalchemy is the right place. I can see that it may be important to opt out of the renaming, and a meta attribute would be a good way to do that.

description=get_column_doc(column),
required=not(is_column_nullable(column)))


@convert_sqlalchemy_type.register(ChoiceType)
def convert_column_to_enum(type, column, registry=None):
def convert_choice_to_enum(type, column, registry=None):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this rename? You are still converting a column here.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The functions must have specific names, because columns with ChoiceType and Enum both convert to Enum. The function name would then be convert_column_to_enum for both. Actually the other functions should also have more specific names, i.e. contain not only the target type, but also the source type.

name = to_type_name(name)
else:
name = 'Enum{}'.format(len(self._registry_enums) + 1)
items = [(key.upper(), key) for key in sql_type.enums]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are doing normalisation here in two places: lines 44 and 52. Wouldn't it be better to do that outside of the if here, to make sure you don't accidentally start using different rules when you make a later change?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I changed that and moved the conversion of enum value names into a separate function.

@jnak
Copy link
Collaborator

jnak commented Apr 10, 2019

Hello everyone,

I'm just catching up with this PR. Since everyone seems to agree that our current Enum situation is not ideal, it's time to make some progress on it.

From what I understand, the main contention point is around the automatic Enum values mangling. While I'm not a big fan of the automatic name mangling, I think it's fine in this case because it is consistent with the rest of graphene-sqlalchemy. Medium-term we should definitely figure out a way to opt-out of this. But I don't think we should block this PR for this because we can already manually override that behavior if we really need to by opting out of the automatic field generation:

class UserType(SQLAlchemyObjectType):
    class Meta:
        model = User
       exclude_fields = ('user_mood',)
    
   user_mood = graphene.Enum(UserMood)
   
  def resolve_user_mood(root, info, **args):
     ...

Let's plan to figure out how to cleanly opt-out of this Enum name mangling as part of a larger PR on how to selectively opt-out of all the name mangling done by graphene-sqlalchemy. There is already one PR addressing this: #178

@Cito Would you be interested in fixing the merge conflicts in the next few days? If not let me know and I'll take care of it.

Cheers,
J

@Cito
Copy link
Member Author

Cito commented Apr 10, 2019

@jnak - yes, this should move forward. I'look into this again in the next days..

Cito added 4 commits April 11, 2019 20:19
This also avoids problems when test tables are changed,
in which case we would need to drop and recreate them.
The created Graphene Enums are now registered and reused,
because their names must be unique in a GraphQL schema.
Also the naming conventions for Enum type names (CamelCase)
and options (UPPER_CASE) are applied when creating them.
@Cito
Copy link
Member Author

Cito commented Apr 11, 2019

@jnak - just pushed anew with some changes and based on the lastest master.

However, I noticed that meanwhile a function has been added to create special enums for sorting. That function caches the enums in a separate cache, not in the registry like my PR does.

I want to change that so that the sorting enums use the same caching mechanism in the registry. Also, I want to change the default function for creating the sort enum value names so that they also use uppercase. Will push another commit tomorrow with these changes.

@jnak
Copy link
Collaborator

jnak commented Apr 12, 2019

@Cito Great. Let me know when it's ready for me to review

Slightly changed the API: Creating and getting sort enums
are different functions now. When no sort enum is created,
a default one will be created automatically.

Sort enums now also use upper case symbols by default.
@Cito
Copy link
Member Author

Cito commented Apr 12, 2019

@jnak - I'm done with this now.

I have changed the API slightly by making the creation of sort enums a separate function. They are created automatically when requested, so you normally don't need manual creation. The old functions are still available with deprecation warnings.

Copy link
Collaborator

@jnak jnak left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great overall. Had a few questions. Thanks!

graphene_sqlalchemy/converter.py Outdated Show resolved Hide resolved
members = OrderedDict(
(to_enum_value_name(key), key) for key in sql_type.enums
)
return self.register_enum(name, members)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does get_type_for_enum need to register the enum? It seems inconsistent with get_type_for_model and get_sort_params_for_model?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The naming of the methods and division between registry and utils modules could certainly be improved. Need to sleep over it, or we refactor this in a new PR.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to address this in this PR because this may lead to unexpected behaviors. For example, as is we are getting different enums for the same un-named sql enum:

reg = Registry()
sa_enum_1 = SQLAlchemyEnum('red', 'blue')
assert reg.get_type_for_enum(sa_enum_1) is reg.get_type_for_enum(sa_enum_1)  # fails

Ideally a getter should not have any side effects. Why can't we just return the enum from the registry?

self. _registry_enums[enum]

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See below, I'll try to come up with a better solution.

else:
self._registry[cls._meta.model] = cls

def register_enum(self, name, members):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does register_enum takes a name instead of SQLAlchemyEnumType? It seems inconsistent with register and register_sort_params

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because not all enums are derived from SQLAlchemyEnumTypes. Some are created for sorting purposes, they don't exist as SQLAlchemyEnumTypes. All of the enums should use the same registry because Enum names in the schema must be unique - you want to get an error when a sort enum accidentally has the same name as an existing enum for a column, and you want to have a way for requesting registered enums by name.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. How about we instead have this function take an enum or a column that maps to SQLAlchemyEnumType to keep all that logic encapsulated here? In both cases, we would be able to extract a name.

Regarding name collisions, the names need to be unique across all types (vs just within Enums). graphene already raises an error to prevent this. Why do we need to handle this in graphene-sqlalchemy as well?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. I will think about the whole mechanism again and try to create a better-thought-out solution. But maybe I'll do this as a new PR.

class Registry(object):
def __init__(self):
def __init__(self, check_duplicate_registration=False):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are the legitimate cases where one would want to disable duplicate registration?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The get_type_for_model() method (used by convert_sqlalchemy_relationship()) assumes that there is only one graphene type per model. You may want to make sure that this assumption is met and you haven't accidentally associated two different types with one model.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This case makes sense to me. I was asking about the legitimate cases where you one does not want this function to raise when there are duplicates.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, ok - good question. Not checking was the behavior until now, and test_types would break because CustomCharacter uses the same model as Character. It only matters if you have relationships. Suggestion: Maybe we should always allow duplicate registration, but memorize all of them instead of only the last. And only when get_type_for_model is called, we throw an error which lists the duplicates. I.e. you only get an error, when this is really a problem.

name = (
to_type_name(name)
if name
else "Enum{}".format(len(self._registry_enums) + 1)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm in theory all columns should have a name. I wonder if this can only happen in our tests because SQLAlchemy is not fully initialized.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the name of the SQLAlchemy Enum type, not the name of the column. That name may not exist.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. What are the benefits of having this function take a SQLAlchemyEnumType instead of a column that maps to a SQLAlchemyEnumType? I can't think of a case where we would have a SQLAlchemyEnumType without its associated column.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You would need to get the SQLAlchemy enum type anyway with column.type. The convert_sqlalchemy_column function already does this. We need the name of the enum type, not the name of the column, because the same enum could be used in different columns.

graphene_sqlalchemy/tests/test_query.py Outdated Show resolved Hide resolved
graphene_sqlalchemy/tests/test_registry.py Show resolved Hide resolved
enum = sort_enum_for_model(Pet)
def test_to_type_name():
assert to_type_name("make_camel_case") == "MakeCamelCase"
assert to_type_name("AlreadyCamelCase") == "AlreadyCamelCase"
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe add a case where it's a mix of snake_case and CamelCase

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok.

graphene_sqlalchemy/tests/test_utils.py Show resolved Hide resolved
@Cito
Copy link
Member Author

Cito commented Apr 13, 2019

@jnak Will be busy until next weekend. I suggest we create a different issue for refactoring the API and making it more consistent. We should decide whether this functionality should be implemented in the registry module or utility module, or maybe in a separate module, and whether/which registry functions we want to replicate in the utility module (a bit more convenient when using the global registry). Maybe we should create a special enum module with support functions for handling enums.

Copy link
Collaborator

@jnak jnak left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No problem - I'm always offline on weekends. I'm open to have a larger discussion about the API in a separate issue. But I'm hesitant to merge this as is because I'm worried that people or PRs start relying on APIs that will potentially change very soon. I completely understand that after all this time you want this to move forward but I think we should take the time we need to get this right. I'm committed to be very responsive until we get this over to the finish line.

class Registry(object):
def __init__(self):
def __init__(self, check_duplicate_registration=False):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This case makes sense to me. I was asking about the legitimate cases where you one does not want this function to raise when there are duplicates.

graphene_sqlalchemy/tests/test_utils.py Show resolved Hide resolved
name = (
to_type_name(name)
if name
else "Enum{}".format(len(self._registry_enums) + 1)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. What are the benefits of having this function take a SQLAlchemyEnumType instead of a column that maps to a SQLAlchemyEnumType? I can't think of a case where we would have a SQLAlchemyEnumType without its associated column.

members = OrderedDict(
(to_enum_value_name(key), key) for key in sql_type.enums
)
return self.register_enum(name, members)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer to address this in this PR because this may lead to unexpected behaviors. For example, as is we are getting different enums for the same un-named sql enum:

reg = Registry()
sa_enum_1 = SQLAlchemyEnum('red', 'blue')
assert reg.get_type_for_enum(sa_enum_1) is reg.get_type_for_enum(sa_enum_1)  # fails

Ideally a getter should not have any side effects. Why can't we just return the enum from the registry?

self. _registry_enums[enum]

else:
self._registry[cls._meta.model] = cls

def register_enum(self, name, members):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. How about we instead have this function take an enum or a column that maps to SQLAlchemyEnumType to keep all that logic encapsulated here? In both cases, we would be able to extract a name.

Regarding name collisions, the names need to be unique across all types (vs just within Enums). graphene already raises an error to prevent this. Why do we need to handle this in graphene-sqlalchemy as well?

@Cito
Copy link
Member Author

Cito commented Apr 18, 2019

@jnak Let's move the discussion to #208, because this review really gets convoluted. I will then create a new PR based on the outcome of the discussion.

@Cito
Copy link
Member Author

Cito commented Apr 27, 2019

This has been superseded by #210 now after discussion in #208.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

9 participants