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

refactor!: use the same conversion system for hybrids and columns #371

Merged
merged 13 commits into from
Jan 4, 2023

Conversation

erikwrede
Copy link
Member

@erikwrede erikwrede commented Dec 25, 2022

This PR refactors the type conversion system to use the same converters for all column types (column + hybrid). Previously, we had convert_sqlalchemy_column using singledispatch and convert_sqlalchemy_hybrid_property_type using a custom singledispatch-like solution to match column and hybrid property type(-hints) to their graphene equivalents. This implied having to maintain two separate type conversion systems with very similar functionality. This PR proposes merging the systems based on our custom singledispatch-style approach. This has several implications:

Breaking Change: convert_sqlalchemy_type now uses a matcher function

Our custom matcher needs to be able to check more than just the type of a column. Some type hints like unions or optionals need further processing. To convert old singledispatch-style converters to the new converters, the column type has to be wrapped in our custom value_equals helper function:

# Before 
@convert_sqlalchemy_type.register(JSON)

# After
@convert_sqlalchemy_type.register(value_equals(JSON))

Breaking Change: convert_sqlalchemy_type support for subtypes is dropped, each column type must be explicitly registered

Since we now use matcher functions, it doesn't make sense to check if any inherited types match the singledispatch converters, as they might interfere with other matchers. Because of that, all types must now be explicitly registered:

@convert_sqlalchemy_type.register(value_equals(MYColumnType))
def convert_my_type(column_type, **kwargs):
   return MyGrapheneType

See converter.py for more detailed examples.

Breaking Change: The first argument to converter functions is always a type

Previously, both type and instances of types were passed to the converter. (String vs String(30)). If you need information from the instance, use column.type instead. Remember to check if column is present and not a hybrid_property

Example:

@convert_sqlalchemy_type.register(column_type_eq(sqa_types.ARRAY))
@convert_sqlalchemy_type.register(column_type_eq(postgresql.ARRAY))
def convert_array_to_list(
    type_arg: Any,
    column: Optional[Union[MapperProperty, hybrid_property]] = None,
    registry: Registry = None,
    **kwargs,
):
    if column is None or isinstance(column, hybrid_property):
        raise Exception("SQL-Array conversion requires a column")
    item_type = column.type.item_type
    if not isinstance(item_type, type):
        item_type = type(item_type)
    inner_type = convert_sqlalchemy_type(
        item_type, column=column, registry=registry, **kwargs
    )
    return graphene.List(
        init_array_list_recursive(inner_type, (column.type.dimensions or 1) - 1)
    )

Breaking Change: convert_sqlalchemy_type's column and registry arguments must now be keyword arguments

To make additional support for type conversions possible, we converted all arguments to the converters to keyword arguments. It is not guaranteed for these to be present, so you should always check for existance of column or registry if you plan on using them.
Since other arguments might be provided as well, make sure that **kwargs is present on your signature and that it is passed to any calls of nested type conversions (e.g. List[JSON] calls convert_list, then convert_list starts another conversion for the inner type)

Breaking Change: The hybrid property default column type is no longer a string. If no matching column type was found, an exception will be raised.

We introduced warnings for the string fallback in 3.0.0b2 as a soft transition to the new system. Since we merged both conversion systems, it is impractical to have a fallback for all types. If you want to keep the old string types for your hybrids, please use:

class MyType(SQLAlchemyObjectType):
    class Meta:
        model = MyModelWithHybrid
    myHybrid = ORMField(type_=graphene.String)

fix: insert missing create_type in union conversion

Breaking Change: convert_sqlalchemy_type now uses a matcher function
Breaking Change: convert_sqlalchemy type's column and registry arguments must now be keyword arguments
Breaking Change: convert_sqlalchemy_type support for subtypes is dropped, each column type must be explicitly registered
Breaking Change: The hybrid property default column type is no longer a string. If no matching column type was found, an exception will be raised.

Signed-off-by: Erik Wrede <[email protected]>
Comment on lines +199 to +203
def register(self, matcher_function: Callable[[Any], bool], func=None):
if func is None:
return lambda f: self.register(matcher_function, f)
self.registry[matcher_function] = func
return func
Copy link
Member Author

@erikwrede erikwrede Dec 25, 2022

Choose a reason for hiding this comment

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

Had to adapt this to functools' SingleDispatch to allow directive chaining

graphene_sqlalchemy/utils.py Outdated Show resolved Hide resolved
graphene_sqlalchemy/tests/models.py Outdated Show resolved Hide resolved
@erikwrede erikwrede changed the title refactor!: use the same conversion system for hybrids and columns fix: insert missing create_type in union conversion refactor!: use the same conversion system for hybrids and columns Dec 25, 2022
@codecov
Copy link

codecov bot commented Dec 25, 2022

Codecov Report

Base: 96.24% // Head: 96.34% // Increases project coverage by +0.09% 🎉

Coverage data is based on head (3923322) compared to base (a03e74d).
Patch coverage: 96.71% of modified lines in pull request are covered.

Additional details and impacted files
@@            Coverage Diff             @@
##           master     #371      +/-   ##
==========================================
+ Coverage   96.24%   96.34%   +0.09%     
==========================================
  Files           9        9              
  Lines         879      903      +24     
==========================================
+ Hits          846      870      +24     
  Misses         33       33              
Impacted Files Coverage Δ
graphene_sqlalchemy/converter.py 95.73% <96.32%> (-0.53%) ⬇️
graphene_sqlalchemy/registry.py 100.00% <100.00%> (ø)
graphene_sqlalchemy/utils.py 95.86% <100.00%> (+2.00%) ⬆️

Help us with your feedback. Take ten seconds to tell us how you rate us. Have a feature suggestion? Share it here.

☔ View full report at Codecov.
📢 Do you have feedback about the report comment? Let us know in this issue.

flipbit03
flipbit03 previously approved these changes Dec 26, 2022
Copy link
Contributor

@flipbit03 flipbit03 left a comment

Choose a reason for hiding this comment

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

LGTM! :shipit:

Copy link
Collaborator

@sabard sabard left a comment

Choose a reason for hiding this comment

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

nice—much cleaner! looking forward to seeing the rest of the changes.

pass

@hybrid_property
def hybrid_prop(self) -> "MyTypeNotInRegistry":
Copy link
Collaborator

Choose a reason for hiding this comment

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

worth testing that this fails for a class type not in the registry 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.

Good point, added that!

Comment on lines 242 to 243
# ToDo Move to a separate converter later, matching all sqlalchemy mapped
# types and returning a dynamic
Copy link
Collaborator

Choose a reason for hiding this comment

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

until this is done, there will still be the issue with initialization order, right?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, I moved it to a function now but will change it to a dynamic later so we can recognize and wrap it in another dynamic in the filter PR - similar to how we do it with relationship filters.

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 I realized we can't use Dynamic here, because graphene can't unpack nested dynamics (e.g. graphene.List(graphene.Dynamic))

So we'll need to check if a field type is a function (inspect.isfunction(_type) or isinstance(_type, partial)) additionally to the instanceof dynamiccheck in the filtering PR. This way we still maintain lazy support here.

registry: Registry = None,
**kwargs,
):
# fixme drop the primary key processing from here in another pr
Copy link
Collaborator

Choose a reason for hiding this comment

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

what's the preferred way of handling primary_keys?

Copy link
Collaborator

Choose a reason for hiding this comment

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

#102 is relevant 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.

Exactly what was mentioned in #102 and also the discussion we recently had about graphql-python/graphene#1428
Out of scope for this refactor though, just added the fixme so it's marked in the code

@convert_sqlalchemy_type.register(column_type_eq(float))
@convert_sqlalchemy_type.register(column_type_eq(sqa_types.Float))
@convert_sqlalchemy_type.register(column_type_eq(sqa_types.Numeric))
@convert_sqlalchemy_type.register(column_type_eq(sqa_types.BigInteger))
Copy link
Collaborator

Choose a reason for hiding this comment

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

maybe a bit off topic here, but wondering about the choice of BigInteger->Float. assume this would break down after 64 bits. any plans for graphql-core to provide a BigInt type like this?

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, let's put this into an issue. Will have a look at it later.

Copy link
Member Author

Choose a reason for hiding this comment

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

Started #373

@erikwrede
Copy link
Member Author

@sabard All fixed up, feel free to merge if nothing else comes up :)

Copy link
Collaborator

@sabard sabard left a comment

Choose a reason for hiding this comment

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

lgtm!

@erikwrede erikwrede merged commit 2041835 into graphql-python:master Jan 4, 2023
ghost pushed a commit to AsimovBio/graphene-sqlalchemy-filter that referenced this pull request Jan 18, 2023
@guillaumep
Copy link

I've had regression in my code after upgrading graphene-sqlalchemy and it just by reading the changes one by one that I saw these breaking changes. I'd like to suggest such breaking changes to be indicated in the release notes otherwise they are hard to find.

@erikwrede
Copy link
Member Author

erikwrede commented Sep 27, 2023

@guillaumep sorry about that, it seems like the release notes for this release were not published properly. We will fix that up soon.
As a tip: in the change list, we will use semantic commits in the future, and in the notes of this release, this PR was already marked with a !, which suggests a breaking change. Of course I acknowledge that this should not be the only information, and as you've seen from the other release notes, we usually publish more detailed information. For the 3.0 full release, there will be a full upgrade guide.

Apart from that, how is this change working for you? We haven't received too much feedback yet, so I'd love to hear your thoughts.

@guillaumep
Copy link

sorry about that, it seems like the release notes for this release were not published properly. We will fix that up soon.

Don't worry about it, and thanks for listening to my feedback.

As a tip: in the change list, we will use semantic commits in the future, and in the notes of this release, this PR was already marked with a !, which suggests a breaking change.

I was not completely aware of this convention in open source projects. I'll pay more attention to it starting now.

For the 3.0 full release, there will be a full upgrade guide.

Great!

Apart from that, how is this change working for you? We haven't received too much feedback yet, so I'd love to hear your thoughts.

I upgraded from 3.0.0b3 to 3.0.0b4. Once I figured out how to change my code everything worked pretty well. We register a custom multi lingual dictionary SQLAlchemy type, which serializes data to a string in the database. We are providing the data as an object to the API clients. Here's the code diff for the upgrade:

from graphene_sqlalchemy.converter import convert_sqlalchemy_type
+from graphene_sqlalchemy.utils import column_type_eq

-@convert_sqlalchemy_type.register(ModelMlText)
+@convert_sqlalchemy_type.register(column_type_eq(ModelMlText))
def convert_mltext_to_json(type, column, registry=None):
    return MlText

The key here was to figure-out the use of column_type_eq. I read graphene-sqlalchemy's source code to see existing examples to figure this one out.

Thanks for the ongoing work on the project!

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.

4 participants