-
Notifications
You must be signed in to change notification settings - Fork 228
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
Conversation
1 similar comment
1 similar comment
I think the enum should not be modified here (CamelCase type names and UPPERCASE options). Because it would be inconsistent with what |
@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:
In SQLAlchemy you would write this type as
It makes sense to convert this to a Graphene GraphQL Enum Type with he name 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:
In this case, the type name would be converted to 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
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
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:
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. |
@Cito, thanks for the explanation.
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 |
@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 |
Any thoughts on if this is going to be merged or how should one handle enum's ? |
@Cito @Fedalto @syrusakbary Any thoughts or recommendations on how one should proceed with implementing Enums as Graphene enums ? |
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: |
Hi, I'd love to know if this can be merged - @Cito, @Fedalto, @syrusakbary, any further thoughts from your perspective? |
No further thoughts from my side. Would also love to see graphene-sqlalchemy moving forward. |
There was a problem hiding this 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): |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
graphene_sqlalchemy/registry.py
Outdated
name = to_type_name(name) | ||
else: | ||
name = 'Enum{}'.format(len(self._registry_enums) + 1) | ||
items = [(key.upper(), key) for key in sql_type.enums] |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
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
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 @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, |
@jnak - yes, this should move forward. I'look into this again in the next days.. |
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.
@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. |
@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.
@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. |
There was a problem hiding this 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!
members = OrderedDict( | ||
(to_enum_value_name(key), key) for key in sql_type.enums | ||
) | ||
return self.register_enum(name, members) |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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]
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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" |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ok.
@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. |
There was a problem hiding this 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): |
There was a problem hiding this comment.
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.
name = ( | ||
to_type_name(name) | ||
if name | ||
else "Enum{}".format(len(self._registry_enums) + 1) |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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): |
There was a problem hiding this comment.
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?
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).