From d66f1d09172852ea08b44ac39951da9ff263debf Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Mon, 26 Dec 2022 00:18:02 +0100 Subject: [PATCH 01/12] refactor!: use the same conversion system for hybrids and columns 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 --- graphene_sqlalchemy/converter.py | 312 +++++++++++--------- graphene_sqlalchemy/registry.py | 6 +- graphene_sqlalchemy/tests/models.py | 10 +- graphene_sqlalchemy/tests/test_converter.py | 6 +- graphene_sqlalchemy/tests/test_registry.py | 4 +- graphene_sqlalchemy/utils.py | 19 +- 6 files changed, 193 insertions(+), 164 deletions(-) diff --git a/graphene_sqlalchemy/converter.py b/graphene_sqlalchemy/converter.py index d3ae812..6b41a09 100644 --- a/graphene_sqlalchemy/converter.py +++ b/graphene_sqlalchemy/converter.py @@ -2,14 +2,13 @@ import sys import typing import uuid -import warnings from decimal import Decimal -from functools import singledispatch -from typing import Any, cast +from typing import Any, Optional, Union, cast from sqlalchemy import types as sqa_types from sqlalchemy.dialects import postgresql -from sqlalchemy.orm import interfaces, strategies +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import MapperProperty, interfaces, strategies import graphene from graphene.types.json import JSONString @@ -17,7 +16,7 @@ from .batching import get_batch_resolver from .enums import enum_for_sa_enum from .fields import BatchSQLAlchemyConnectionField, default_connection_field_factory -from .registry import get_global_registry +from .registry import Registry, get_global_registry from .resolvers import get_attr_resolver, get_custom_resolver from .utils import ( DummyImport, @@ -25,6 +24,7 @@ safe_isinstance, singledispatchbymatchfunction, value_equals, + value_equals_strict, ) try: @@ -210,7 +210,9 @@ def convert_sqlalchemy_column(column_prop, registry, resolver, **field_kwargs): field_kwargs.setdefault( "type_", - convert_sqlalchemy_type(getattr(column, "type", None), column, registry), + convert_sqlalchemy_type( + getattr(column, "type", None), column=column, registry=registry + ), ) field_kwargs.setdefault("required", not is_column_nullable(column)) field_kwargs.setdefault("description", get_column_doc(column)) @@ -218,86 +220,148 @@ def convert_sqlalchemy_column(column_prop, registry, resolver, **field_kwargs): return graphene.Field(resolver=resolver, **field_kwargs) -@singledispatch -def convert_sqlalchemy_type(type, column, registry=None): +@singledispatchbymatchfunction +def convert_sqlalchemy_type( + type_arg: Any, + column: Optional[Union[MapperProperty, hybrid_property]] = None, + registry: Registry = None, + **kwargs, +): + existing_graphql_type = get_global_registry().get_type_for_model(type_arg) + if existing_graphql_type: + return existing_graphql_type + + if isinstance(type_arg, type(graphene.ObjectType)): + return type_arg + + if isinstance(type_arg, type(graphene.Scalar)): + return type_arg + + # No valid type found, warn and fall back to graphene.String raise Exception( - "Don't know how to convert the SQLAlchemy field %s (%s)" - % (column, column.__class__) + "Don't know how to convert the SQLAlchemy field %s (%s, %s)" + % (column, column.__class__ or "no column provided", type_arg) ) -@convert_sqlalchemy_type.register(sqa_types.String) -@convert_sqlalchemy_type.register(sqa_types.Text) -@convert_sqlalchemy_type.register(sqa_types.Unicode) -@convert_sqlalchemy_type.register(sqa_types.UnicodeText) -@convert_sqlalchemy_type.register(postgresql.INET) -@convert_sqlalchemy_type.register(postgresql.CIDR) -@convert_sqlalchemy_type.register(sqa_utils.TSVectorType) -@convert_sqlalchemy_type.register(sqa_utils.EmailType) -@convert_sqlalchemy_type.register(sqa_utils.URLType) -@convert_sqlalchemy_type.register(sqa_utils.IPAddressType) -def convert_column_to_string(type, column, registry=None): +@convert_sqlalchemy_type.register(value_equals_strict(str)) +@convert_sqlalchemy_type.register(value_equals(sqa_types.String)) +@convert_sqlalchemy_type.register(value_equals(sqa_types.Text)) +@convert_sqlalchemy_type.register(value_equals(sqa_types.Unicode)) +@convert_sqlalchemy_type.register(value_equals(sqa_types.UnicodeText)) +@convert_sqlalchemy_type.register(value_equals(postgresql.INET)) +@convert_sqlalchemy_type.register(value_equals(postgresql.CIDR)) +@convert_sqlalchemy_type.register(value_equals(sqa_utils.TSVectorType)) +@convert_sqlalchemy_type.register(value_equals(sqa_utils.EmailType)) +@convert_sqlalchemy_type.register(value_equals(sqa_utils.URLType)) +@convert_sqlalchemy_type.register(value_equals(sqa_utils.IPAddressType)) +def convert_column_to_string(type_arg: Any, **kwargs): return graphene.String -@convert_sqlalchemy_type.register(postgresql.UUID) -@convert_sqlalchemy_type.register(sqa_utils.UUIDType) -def convert_column_to_uuid(type, column, registry=None): +@convert_sqlalchemy_type.register(value_equals(postgresql.UUID)) +@convert_sqlalchemy_type.register(value_equals(sqa_utils.UUIDType)) +@convert_sqlalchemy_type.register(value_equals(uuid.UUID)) +def convert_column_to_uuid( + type_arg: Any, + **kwargs, +): return graphene.UUID -@convert_sqlalchemy_type.register(sqa_types.DateTime) -def convert_column_to_datetime(type, column, registry=None): +@convert_sqlalchemy_type.register(value_equals(sqa_types.DateTime)) +@convert_sqlalchemy_type.register(value_equals(datetime.datetime)) +def convert_column_to_datetime( + type_arg: Any, + **kwargs, +): return graphene.DateTime -@convert_sqlalchemy_type.register(sqa_types.Time) -def convert_column_to_time(type, column, registry=None): +@convert_sqlalchemy_type.register(value_equals(sqa_types.Time)) +@convert_sqlalchemy_type.register(value_equals(datetime.time)) +def convert_column_to_time( + type_arg: Any, + **kwargs, +): return graphene.Time -@convert_sqlalchemy_type.register(sqa_types.Date) -def convert_column_to_date(type, column, registry=None): +@convert_sqlalchemy_type.register(value_equals(sqa_types.Date)) +@convert_sqlalchemy_type.register(value_equals(datetime.date)) +def convert_column_to_date( + type_arg: Any, + **kwargs, +): return graphene.Date -@convert_sqlalchemy_type.register(sqa_types.SmallInteger) -@convert_sqlalchemy_type.register(sqa_types.Integer) -def convert_column_to_int_or_id(type, column, registry=None): - return graphene.ID if column.primary_key else graphene.Int +@convert_sqlalchemy_type.register(value_equals(sqa_types.SmallInteger)) +@convert_sqlalchemy_type.register(value_equals(sqa_types.Integer)) +@convert_sqlalchemy_type.register(value_equals_strict(int)) +def convert_column_to_int_or_id( + type_arg: Any, + column: Optional[Union[MapperProperty, hybrid_property]] = None, + registry: Registry = None, + **kwargs, +): + return ( + graphene.ID if (column is not None) and column.primary_key else graphene.Int + ) # fixme drop the primary key processing in another pr -@convert_sqlalchemy_type.register(sqa_types.Boolean) -def convert_column_to_boolean(type, column, registry=None): +@convert_sqlalchemy_type.register(value_equals(sqa_types.Boolean)) +@convert_sqlalchemy_type.register(value_equals_strict(bool)) +def convert_column_to_boolean( + type_arg: Any, + **kwargs, +): return graphene.Boolean -@convert_sqlalchemy_type.register(sqa_types.Float) -@convert_sqlalchemy_type.register(sqa_types.Numeric) -@convert_sqlalchemy_type.register(sqa_types.BigInteger) -def convert_column_to_float(type, column, registry=None): +@convert_sqlalchemy_type.register(value_equals_strict(float)) +@convert_sqlalchemy_type.register(value_equals(sqa_types.Float)) +@convert_sqlalchemy_type.register(value_equals(sqa_types.Numeric)) +@convert_sqlalchemy_type.register(value_equals(sqa_types.BigInteger)) +def convert_column_to_float( + type_arg: Any, + **kwargs, +): return graphene.Float -@convert_sqlalchemy_type.register(sqa_types.Enum) -def convert_enum_to_enum(type, column, registry=None): - return lambda: enum_for_sa_enum(type, registry or get_global_registry()) +@convert_sqlalchemy_type.register(value_equals(postgresql.ENUM)) +@convert_sqlalchemy_type.register(value_equals(sqa_types.Enum)) +def convert_enum_to_enum( + type_arg: Any, + column: Optional[Union[MapperProperty, hybrid_property]] = None, + registry: Registry = None, + **kwargs, +): + return lambda: enum_for_sa_enum(type_arg, registry or get_global_registry()) # TODO Make ChoiceType conversion consistent with other enums -@convert_sqlalchemy_type.register(sqa_utils.ChoiceType) -def convert_choice_to_enum(type, column, registry=None): +@convert_sqlalchemy_type.register(value_equals(sqa_utils.ChoiceType)) +def convert_choice_to_enum( + type_arg: sqa_utils.ChoiceType, + column: Optional[Union[MapperProperty, hybrid_property]] = None, + registry: Registry = None, +): name = "{}_{}".format(column.table.name, column.key).upper() - if isinstance(type.type_impl, EnumTypeImpl): + if isinstance(type_arg.type_impl, EnumTypeImpl): # type.choices may be Enum/IntEnum, in ChoiceType both presented as EnumMeta # do not use from_enum here because we can have more than one enum column in table - return graphene.Enum(name, list((v.name, v.value) for v in type.choices)) + return graphene.Enum(name, list((v.name, v.value) for v in type_arg.choices)) else: - return graphene.Enum(name, type.choices) + return graphene.Enum(name, type_arg.choices) -@convert_sqlalchemy_type.register(sqa_utils.ScalarListType) -def convert_scalar_list_to_list(type, column, registry=None): +@convert_sqlalchemy_type.register(value_equals(sqa_utils.ScalarListType)) +def convert_scalar_list_to_list( + type_arg: Any, + **kwargs, +): return graphene.List(graphene.String) @@ -309,108 +373,68 @@ def init_array_list_recursive(inner_type, n): ) -@convert_sqlalchemy_type.register(sqa_types.ARRAY) -@convert_sqlalchemy_type.register(postgresql.ARRAY) -def convert_array_to_list(_type, column, registry=None): - inner_type = convert_sqlalchemy_type(column.type.item_type, column) +@convert_sqlalchemy_type.register(value_equals(sqa_types.ARRAY)) +@convert_sqlalchemy_type.register(value_equals(postgresql.ARRAY)) +def convert_array_to_list( + type_arg: Any, + column: Optional[Union[MapperProperty, hybrid_property]] = None, + registry: Registry = None, + **kwargs, +): + inner_type = convert_sqlalchemy_type( + column.type.item_type, column=column, registry=registry, **kwargs + ) return graphene.List( init_array_list_recursive(inner_type, (column.type.dimensions or 1) - 1) ) -@convert_sqlalchemy_type.register(postgresql.HSTORE) -@convert_sqlalchemy_type.register(postgresql.JSON) -@convert_sqlalchemy_type.register(postgresql.JSONB) -def convert_json_to_string(type, column, registry=None): +@convert_sqlalchemy_type.register(value_equals(postgresql.HSTORE)) +@convert_sqlalchemy_type.register(value_equals(postgresql.JSON)) +@convert_sqlalchemy_type.register(value_equals(postgresql.JSONB)) +def convert_json_to_string( + type_arg: Any, + **kwargs, +): return JSONString -@convert_sqlalchemy_type.register(sqa_utils.JSONType) -@convert_sqlalchemy_type.register(sqa_types.JSON) -def convert_json_type_to_string(type, column, registry=None): +@convert_sqlalchemy_type.register(value_equals(sqa_utils.JSONType)) +@convert_sqlalchemy_type.register(value_equals(sqa_types.JSON)) +def convert_json_type_to_string( + type_arg: Any, + **kwargs, +): return JSONString -@convert_sqlalchemy_type.register(sqa_types.Variant) -def convert_variant_to_impl_type(type, column, registry=None): - return convert_sqlalchemy_type(type.impl, column, registry=registry) - - -@singledispatchbymatchfunction -def convert_sqlalchemy_hybrid_property_type(arg: Any): - existing_graphql_type = get_global_registry().get_type_for_model(arg) - if existing_graphql_type: - return existing_graphql_type - - if isinstance(arg, type(graphene.ObjectType)): - return arg - - if isinstance(arg, type(graphene.Scalar)): - return arg - - # No valid type found, warn and fall back to graphene.String - warnings.warn( - f'I don\'t know how to generate a GraphQL type out of a "{arg}" type.' - 'Falling back to "graphene.String"' +@convert_sqlalchemy_type.register(value_equals(sqa_types.Variant)) +def convert_variant_to_impl_type( + type_arg: sqa_types.Variant, + column: Optional[Union[MapperProperty, hybrid_property]] = None, + registry: Registry = None, + **kwargs, +): + return convert_sqlalchemy_type( + type_arg.impl, column=column, registry=registry, **kwargs ) - return graphene.String - - -@convert_sqlalchemy_hybrid_property_type.register(value_equals(str)) -def convert_sqlalchemy_hybrid_property_type_str(arg): - return graphene.String - -@convert_sqlalchemy_hybrid_property_type.register(value_equals(int)) -def convert_sqlalchemy_hybrid_property_type_int(arg): - return graphene.Int - -@convert_sqlalchemy_hybrid_property_type.register(value_equals(float)) -def convert_sqlalchemy_hybrid_property_type_float(arg): - return graphene.Float - - -@convert_sqlalchemy_hybrid_property_type.register(value_equals(Decimal)) -def convert_sqlalchemy_hybrid_property_type_decimal(arg): +@convert_sqlalchemy_type.register(value_equals_strict(Decimal)) +def convert_sqlalchemy_hybrid_property_type_decimal(type_arg: Any, **kwargs): # The reason Decimal should be serialized as a String is because this is a # base10 type used in things like money, and string allows it to not # lose precision (which would happen if we downcasted to a Float, for example) return graphene.String -@convert_sqlalchemy_hybrid_property_type.register(value_equals(bool)) -def convert_sqlalchemy_hybrid_property_type_bool(arg): - return graphene.Boolean - - -@convert_sqlalchemy_hybrid_property_type.register(value_equals(datetime.datetime)) -def convert_sqlalchemy_hybrid_property_type_datetime(arg): - return graphene.DateTime - - -@convert_sqlalchemy_hybrid_property_type.register(value_equals(datetime.date)) -def convert_sqlalchemy_hybrid_property_type_date(arg): - return graphene.Date - - -@convert_sqlalchemy_hybrid_property_type.register(value_equals(datetime.time)) -def convert_sqlalchemy_hybrid_property_type_time(arg): - return graphene.Time - - -@convert_sqlalchemy_hybrid_property_type.register(value_equals(uuid.UUID)) -def convert_sqlalchemy_hybrid_property_type_uuid(arg): - return graphene.UUID - - -def is_union(arg) -> bool: +def is_union(type_arg: Any, **kwargs) -> bool: if sys.version_info >= (3, 10): from types import UnionType - if isinstance(arg, UnionType): + if isinstance(type_arg, UnionType): return True - return getattr(arg, "__origin__", None) == typing.Union + return getattr(type_arg, "__origin__", None) == typing.Union def graphene_union_for_py_union( @@ -421,14 +445,14 @@ def graphene_union_for_py_union( if union_type is None: # Union Name is name of the three union_name = "".join(sorted(obj_type._meta.name for obj_type in obj_types)) - union_type = graphene.Union(union_name, obj_types) + union_type = graphene.Union.create_type(union_name, types=obj_types) registry.register_union_type(union_type, obj_types) return union_type -@convert_sqlalchemy_hybrid_property_type.register(is_union) -def convert_sqlalchemy_hybrid_property_union(arg): +@convert_sqlalchemy_type.register(is_union) +def convert_sqlalchemy_hybrid_property_union(type_arg: Any, **kwargs): """ Converts Unions (Union[X,Y], or X | Y for python > 3.10) to the corresponding graphene schema object. Since Optionals are internally represented as Union[T, ], they are handled here as well. @@ -444,11 +468,11 @@ def convert_sqlalchemy_hybrid_property_union(arg): # Option is actually Union[T, ] # Just get the T out of the list of arguments by filtering out the NoneType - nested_types = list(filter(lambda x: not type(None) == x, arg.__args__)) + nested_types = list(filter(lambda x: not type(None) == x, type_arg.__args__)) # Map the graphene types to the nested types. # We use convert_sqlalchemy_hybrid_property_type instead of the registry to account for ForwardRefs, Lists,... - graphene_types = list(map(convert_sqlalchemy_hybrid_property_type, nested_types)) + graphene_types = list(map(convert_sqlalchemy_type, nested_types)) # If only one type is left after filtering out NoneType, the Union was an Optional if len(graphene_types) == 1: @@ -471,20 +495,20 @@ def convert_sqlalchemy_hybrid_property_union(arg): ) -@convert_sqlalchemy_hybrid_property_type.register( +@convert_sqlalchemy_type.register( lambda x: getattr(x, "__origin__", None) in [list, typing.List] ) -def convert_sqlalchemy_hybrid_property_type_list_t(arg): +def convert_sqlalchemy_hybrid_property_type_list_t(type_arg: Any, **kwargs): # type is either list[T] or List[T], generic argument at __args__[0] - internal_type = arg.__args__[0] + internal_type = type_arg.__args__[0] - graphql_internal_type = convert_sqlalchemy_hybrid_property_type(internal_type) + graphql_internal_type = convert_sqlalchemy_type(internal_type, **kwargs) return graphene.List(graphql_internal_type) -@convert_sqlalchemy_hybrid_property_type.register(safe_isinstance(ForwardRef)) -def convert_sqlalchemy_hybrid_property_forwardref(arg): +@convert_sqlalchemy_type.register(safe_isinstance(ForwardRef)) +def convert_sqlalchemy_hybrid_property_forwardref(type_arg: Any, **kwargs): """ Generate a lambda that will resolve the type at runtime This takes care of self-references @@ -492,7 +516,7 @@ def convert_sqlalchemy_hybrid_property_forwardref(arg): from .registry import get_global_registry def forward_reference_solver(): - model = registry_sqlalchemy_model_from_str(arg.__forward_arg__) + model = registry_sqlalchemy_model_from_str(type_arg.__forward_arg__) if not model: return graphene.String # Always fall back to string if no ForwardRef type found. @@ -501,17 +525,17 @@ def forward_reference_solver(): return forward_reference_solver -@convert_sqlalchemy_hybrid_property_type.register(safe_isinstance(str)) -def convert_sqlalchemy_hybrid_property_bare_str(arg): +@convert_sqlalchemy_type.register(safe_isinstance(str)) +def convert_sqlalchemy_hybrid_property_bare_str(type_arg: str, **kwargs): """ Convert Bare String into a ForwardRef """ - return convert_sqlalchemy_hybrid_property_type(ForwardRef(arg)) + return convert_sqlalchemy_type(ForwardRef(type_arg), **kwargs) def convert_hybrid_property_return_type(hybrid_prop): # Grab the original method's return type annotations from inside the hybrid property return_type_annotation = hybrid_prop.fget.__annotations__.get("return", str) - return convert_sqlalchemy_hybrid_property_type(return_type_annotation) + return convert_sqlalchemy_type(return_type_annotation) diff --git a/graphene_sqlalchemy/registry.py b/graphene_sqlalchemy/registry.py index cc4b02b..3c46301 100644 --- a/graphene_sqlalchemy/registry.py +++ b/graphene_sqlalchemy/registry.py @@ -83,13 +83,13 @@ def get_sort_enum_for_object_type(self, obj_type: graphene.ObjectType): return self._registry_sort_enums.get(obj_type) def register_union_type( - self, union: graphene.Union, obj_types: List[Type[graphene.ObjectType]] + self, union: Type[graphene.Union], obj_types: List[Type[graphene.ObjectType]] ): - if not isinstance(union, graphene.Union): + if not issubclass(union, graphene.Union): raise TypeError("Expected graphene.Union, but got: {!r}".format(union)) for obj_type in obj_types: - if not isinstance(obj_type, type(graphene.ObjectType)): + if not issubclass(obj_type, graphene.ObjectType): raise TypeError( "Expected Graphene ObjectType, but got: {!r}".format(obj_type) ) diff --git a/graphene_sqlalchemy/tests/models.py b/graphene_sqlalchemy/tests/models.py index ee28658..d02ee27 100644 --- a/graphene_sqlalchemy/tests/models.py +++ b/graphene_sqlalchemy/tests/models.py @@ -4,7 +4,7 @@ import enum import uuid from decimal import Decimal -from typing import List, Optional, Tuple +from typing import List, Optional from sqlalchemy import ( Column, @@ -254,9 +254,11 @@ def hybrid_prop_shopping_cart_item_list(self) -> List[ShoppingCartItem]: return [ShoppingCartItem(id=1), ShoppingCartItem(id=2)] # Unsupported Type - @hybrid_property - def hybrid_prop_unsupported_type_tuple(self) -> Tuple[str, str]: - return "this will actually", "be a string" + # fixme move this somewhere else + # + # @hybrid_property + # def hybrid_prop_unsupported_type_tuple(self) -> Tuple[str, str]: + # return "this will actually", "be a string" # Self-references diff --git a/graphene_sqlalchemy/tests/test_converter.py b/graphene_sqlalchemy/tests/test_converter.py index b9a1c15..5b2c12d 100644 --- a/graphene_sqlalchemy/tests/test_converter.py +++ b/graphene_sqlalchemy/tests/test_converter.py @@ -131,11 +131,10 @@ def prop_method_2() -> Union[ShoppingCartType, PetType]: field_type_1 = get_hybrid_property_type(prop_method).type field_type_2 = get_hybrid_property_type(prop_method_2).type - assert isinstance(field_type_1, graphene.Union) + assert issubclass(field_type_1, graphene.Union) + assert field_type_1._meta.types == [PetType, ShoppingCartType] assert field_type_1 is field_type_2 - # TODO verify types of the union - @pytest.mark.skipif( sys.version_info < (3, 10), reason="|-Style Unions are unsupported in python < 3.10" @@ -667,7 +666,6 @@ class Meta: ), "hybrid_prop_first_shopping_cart_item": ShoppingCartItemType, "hybrid_prop_shopping_cart_item_list": graphene.List(ShoppingCartItemType), - "hybrid_prop_unsupported_type_tuple": graphene.String, # Self Referential List "hybrid_prop_self_referential": ShoppingCartType, "hybrid_prop_self_referential_list": graphene.List(ShoppingCartType), diff --git a/graphene_sqlalchemy/tests/test_registry.py b/graphene_sqlalchemy/tests/test_registry.py index 68b5404..e54f08b 100644 --- a/graphene_sqlalchemy/tests/test_registry.py +++ b/graphene_sqlalchemy/tests/test_registry.py @@ -142,7 +142,7 @@ class Meta: model = Reporter union_types = [PetType, ReporterType] - union = graphene.Union("ReporterPet", tuple(union_types)) + union = graphene.Union.create_type("ReporterPet", types=tuple(union_types)) reg.register_union_type(union, union_types) @@ -155,7 +155,7 @@ def test_register_union_scalar(): reg = Registry() union_types = [graphene.String, graphene.Int] - union = graphene.Union("StringInt", tuple(union_types)) + union = graphene.Union.create_type("StringInt", types=union_types) re_err = r"Expected Graphene ObjectType, but got: .*String.*" with pytest.raises(TypeError, match=re_err): diff --git a/graphene_sqlalchemy/utils.py b/graphene_sqlalchemy/utils.py index 62c71d8..59c6798 100644 --- a/graphene_sqlalchemy/utils.py +++ b/graphene_sqlalchemy/utils.py @@ -196,18 +196,23 @@ def __call__(self, *args, **kwargs): # No match, using default. return self.default(*args, **kwargs) - def register(self, matcher_function: Callable[[Any], bool]): - def grab_function_from_outside(f): - self.registry[matcher_function] = f - return self + 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 - return grab_function_from_outside + +def value_equals(value: Any) -> Callable[[Any], bool]: + """A simple function that makes the equality based matcher functions for + SingleDispatchByMatchFunction prettier""" + return lambda x: (x == value or type(x) is value) -def value_equals(value): +def value_equals_strict(value: Any) -> Callable[[Any], bool]: """A simple function that makes the equality based matcher functions for SingleDispatchByMatchFunction prettier""" - return lambda x: x == value + return lambda x: (x == value) def safe_isinstance(cls): From 778fbd760e6ae25123f2226c73b27cde19dd56c3 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Mon, 26 Dec 2022 00:30:51 +0100 Subject: [PATCH 02/12] fix: catch import error in older sqlalchemy versions Signed-off-by: Erik Wrede --- graphene_sqlalchemy/converter.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/graphene_sqlalchemy/converter.py b/graphene_sqlalchemy/converter.py index 6b41a09..af056b6 100644 --- a/graphene_sqlalchemy/converter.py +++ b/graphene_sqlalchemy/converter.py @@ -8,7 +8,7 @@ from sqlalchemy import types as sqa_types from sqlalchemy.dialects import postgresql from sqlalchemy.ext.hybrid import hybrid_property -from sqlalchemy.orm import MapperProperty, interfaces, strategies +from sqlalchemy.orm import interfaces, strategies import graphene from graphene.types.json import JSONString @@ -27,6 +27,13 @@ value_equals_strict, ) +# We just use MapperProperties for type hints, they don't exist in sqlalchemy < 1.4 +try: + from sqlalchemy import MapperProperty +except ImportError: + # sqlalchemy < 1.4 + MapperProperty = Any + try: from typing import ForwardRef except ImportError: From ea6fbdcc2bca473c17a846dfb7177e59bbcd10b5 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Mon, 26 Dec 2022 00:34:18 +0100 Subject: [PATCH 03/12] fix: union test for 3.10 Signed-off-by: Erik Wrede --- graphene_sqlalchemy/tests/test_converter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/graphene_sqlalchemy/tests/test_converter.py b/graphene_sqlalchemy/tests/test_converter.py index 5b2c12d..3b29fca 100644 --- a/graphene_sqlalchemy/tests/test_converter.py +++ b/graphene_sqlalchemy/tests/test_converter.py @@ -163,7 +163,8 @@ def prop_method_2() -> ShoppingCartType | PetType: field_type_1 = get_hybrid_property_type(prop_method).type field_type_2 = get_hybrid_property_type(prop_method_2).type - assert isinstance(field_type_1, graphene.Union) + assert issubclass(field_type_1, graphene.Union) + assert field_type_1._meta.types == [PetType, ShoppingCartType] assert field_type_1 is field_type_2 From 233ece6964bc242c3e987e9b4696e4cda4e6253c Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Mon, 2 Jan 2023 13:24:10 +0100 Subject: [PATCH 04/12] fix: use type and value for all columns Signed-off-by: Erik Wrede --- graphene_sqlalchemy/converter.py | 53 ++++++++++++++------- graphene_sqlalchemy/tests/test_converter.py | 6 +++ graphene_sqlalchemy/utils.py | 6 --- 3 files changed, 42 insertions(+), 23 deletions(-) diff --git a/graphene_sqlalchemy/converter.py b/graphene_sqlalchemy/converter.py index af056b6..44a6a98 100644 --- a/graphene_sqlalchemy/converter.py +++ b/graphene_sqlalchemy/converter.py @@ -24,7 +24,6 @@ safe_isinstance, singledispatchbymatchfunction, value_equals, - value_equals_strict, ) # We just use MapperProperties for type hints, they don't exist in sqlalchemy < 1.4 @@ -214,12 +213,15 @@ def inner(fn): def convert_sqlalchemy_column(column_prop, registry, resolver, **field_kwargs): column = column_prop.columns[0] - + column_type = getattr(column, "type", None) + # The converter expects a type to find the right conversion function. + # If we get an instance instead, we need to convert it to a type. + # The conversion function will still be able to access the instance via the column argument. + if not isinstance(column_type, type): + column_type = type(column_type) field_kwargs.setdefault( "type_", - convert_sqlalchemy_type( - getattr(column, "type", None), column=column, registry=registry - ), + convert_sqlalchemy_type(column_type, column=column, registry=registry), ) field_kwargs.setdefault("required", not is_column_nullable(column)) field_kwargs.setdefault("description", get_column_doc(column)) @@ -251,7 +253,7 @@ def convert_sqlalchemy_type( ) -@convert_sqlalchemy_type.register(value_equals_strict(str)) +@convert_sqlalchemy_type.register(value_equals(str)) @convert_sqlalchemy_type.register(value_equals(sqa_types.String)) @convert_sqlalchemy_type.register(value_equals(sqa_types.Text)) @convert_sqlalchemy_type.register(value_equals(sqa_types.Unicode)) @@ -305,7 +307,7 @@ def convert_column_to_date( @convert_sqlalchemy_type.register(value_equals(sqa_types.SmallInteger)) @convert_sqlalchemy_type.register(value_equals(sqa_types.Integer)) -@convert_sqlalchemy_type.register(value_equals_strict(int)) +@convert_sqlalchemy_type.register(value_equals(int)) def convert_column_to_int_or_id( type_arg: Any, column: Optional[Union[MapperProperty, hybrid_property]] = None, @@ -318,7 +320,7 @@ def convert_column_to_int_or_id( @convert_sqlalchemy_type.register(value_equals(sqa_types.Boolean)) -@convert_sqlalchemy_type.register(value_equals_strict(bool)) +@convert_sqlalchemy_type.register(value_equals(bool)) def convert_column_to_boolean( type_arg: Any, **kwargs, @@ -326,7 +328,7 @@ def convert_column_to_boolean( return graphene.Boolean -@convert_sqlalchemy_type.register(value_equals_strict(float)) +@convert_sqlalchemy_type.register(value_equals(float)) @convert_sqlalchemy_type.register(value_equals(sqa_types.Float)) @convert_sqlalchemy_type.register(value_equals(sqa_types.Numeric)) @convert_sqlalchemy_type.register(value_equals(sqa_types.BigInteger)) @@ -345,7 +347,10 @@ def convert_enum_to_enum( registry: Registry = None, **kwargs, ): - return lambda: enum_for_sa_enum(type_arg, registry or get_global_registry()) + if column is None or isinstance(column, hybrid_property): + raise Exception("SQL-Enum conversion requires a column") + + return lambda: enum_for_sa_enum(column.type, registry or get_global_registry()) # TODO Make ChoiceType conversion consistent with other enums @@ -353,15 +358,18 @@ def convert_enum_to_enum( def convert_choice_to_enum( type_arg: sqa_utils.ChoiceType, column: Optional[Union[MapperProperty, hybrid_property]] = None, - registry: Registry = None, + **kwargs, ): + if column is None or isinstance(column, hybrid_property): + raise Exception("ChoiceType conversion requires a column") + name = "{}_{}".format(column.table.name, column.key).upper() - if isinstance(type_arg.type_impl, EnumTypeImpl): + if isinstance(column.type.type_impl, EnumTypeImpl): # type.choices may be Enum/IntEnum, in ChoiceType both presented as EnumMeta # do not use from_enum here because we can have more than one enum column in table - return graphene.Enum(name, list((v.name, v.value) for v in type_arg.choices)) + return graphene.Enum(name, list((v.name, v.value) for v in column.type.choices)) else: - return graphene.Enum(name, type_arg.choices) + return graphene.Enum(name, column.type.choices) @convert_sqlalchemy_type.register(value_equals(sqa_utils.ScalarListType)) @@ -388,8 +396,13 @@ def convert_array_to_list( 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( - column.type.item_type, column=column, registry=registry, **kwargs + item_type, column=column, registry=registry, **kwargs ) return graphene.List( init_array_list_recursive(inner_type, (column.type.dimensions or 1) - 1) @@ -422,12 +435,18 @@ def convert_variant_to_impl_type( registry: Registry = None, **kwargs, ): + if column is None or isinstance(column, hybrid_property): + raise Exception("Vaiant conversion requires a column") + + type_impl = column.type.impl + if not isinstance(type_impl, type): + type_impl = type(type_impl) return convert_sqlalchemy_type( - type_arg.impl, column=column, registry=registry, **kwargs + type_impl, column=column, registry=registry, **kwargs ) -@convert_sqlalchemy_type.register(value_equals_strict(Decimal)) +@convert_sqlalchemy_type.register(value_equals(Decimal)) def convert_sqlalchemy_hybrid_property_type_decimal(type_arg: Any, **kwargs): # The reason Decimal should be serialized as a String is because this is a # base10 type used in things like money, and string allows it to not diff --git a/graphene_sqlalchemy/tests/test_converter.py b/graphene_sqlalchemy/tests/test_converter.py index 3b29fca..49a4d93 100644 --- a/graphene_sqlalchemy/tests/test_converter.py +++ b/graphene_sqlalchemy/tests/test_converter.py @@ -20,6 +20,7 @@ convert_sqlalchemy_composite, convert_sqlalchemy_hybrid_method, convert_sqlalchemy_relationship, + convert_sqlalchemy_type, ) from ..fields import UnsortedSQLAlchemyConnectionField, default_connection_field_factory from ..registry import Registry, get_global_registry @@ -168,6 +169,11 @@ def prop_method_2() -> ShoppingCartType | PetType: assert field_type_1 is field_type_2 +def test_should_unknown_type_raise_error(): + with pytest.raises(Exception): + converted_type = convert_sqlalchemy_type(ZeroDivisionError) # noqa + + def test_should_datetime_convert_datetime(): assert get_field(types.DateTime()).type == graphene.DateTime diff --git a/graphene_sqlalchemy/utils.py b/graphene_sqlalchemy/utils.py index 59c6798..0f4d4c6 100644 --- a/graphene_sqlalchemy/utils.py +++ b/graphene_sqlalchemy/utils.py @@ -204,12 +204,6 @@ def register(self, matcher_function: Callable[[Any], bool], func=None): def value_equals(value: Any) -> Callable[[Any], bool]: - """A simple function that makes the equality based matcher functions for - SingleDispatchByMatchFunction prettier""" - return lambda x: (x == value or type(x) is value) - - -def value_equals_strict(value: Any) -> Callable[[Any], bool]: """A simple function that makes the equality based matcher functions for SingleDispatchByMatchFunction prettier""" return lambda x: (x == value) From 9bf07dc219c64daea22cb9110aa9954afffe7fbe Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Tue, 3 Jan 2023 11:54:30 +0100 Subject: [PATCH 05/12] refactor: rename value_equals to column_type_eq Signed-off-by: Erik Wrede --- graphene_sqlalchemy/converter.py | 86 ++++++++++++++++---------------- graphene_sqlalchemy/utils.py | 2 +- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/graphene_sqlalchemy/converter.py b/graphene_sqlalchemy/converter.py index 44a6a98..4822f4d 100644 --- a/graphene_sqlalchemy/converter.py +++ b/graphene_sqlalchemy/converter.py @@ -20,10 +20,10 @@ from .resolvers import get_attr_resolver, get_custom_resolver from .utils import ( DummyImport, + column_type_eq, registry_sqlalchemy_model_from_str, safe_isinstance, singledispatchbymatchfunction, - value_equals, ) # We just use MapperProperties for type hints, they don't exist in sqlalchemy < 1.4 @@ -253,24 +253,24 @@ def convert_sqlalchemy_type( ) -@convert_sqlalchemy_type.register(value_equals(str)) -@convert_sqlalchemy_type.register(value_equals(sqa_types.String)) -@convert_sqlalchemy_type.register(value_equals(sqa_types.Text)) -@convert_sqlalchemy_type.register(value_equals(sqa_types.Unicode)) -@convert_sqlalchemy_type.register(value_equals(sqa_types.UnicodeText)) -@convert_sqlalchemy_type.register(value_equals(postgresql.INET)) -@convert_sqlalchemy_type.register(value_equals(postgresql.CIDR)) -@convert_sqlalchemy_type.register(value_equals(sqa_utils.TSVectorType)) -@convert_sqlalchemy_type.register(value_equals(sqa_utils.EmailType)) -@convert_sqlalchemy_type.register(value_equals(sqa_utils.URLType)) -@convert_sqlalchemy_type.register(value_equals(sqa_utils.IPAddressType)) +@convert_sqlalchemy_type.register(column_type_eq(str)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_types.String)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_types.Text)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_types.Unicode)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_types.UnicodeText)) +@convert_sqlalchemy_type.register(column_type_eq(postgresql.INET)) +@convert_sqlalchemy_type.register(column_type_eq(postgresql.CIDR)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_utils.TSVectorType)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_utils.EmailType)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_utils.URLType)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_utils.IPAddressType)) def convert_column_to_string(type_arg: Any, **kwargs): return graphene.String -@convert_sqlalchemy_type.register(value_equals(postgresql.UUID)) -@convert_sqlalchemy_type.register(value_equals(sqa_utils.UUIDType)) -@convert_sqlalchemy_type.register(value_equals(uuid.UUID)) +@convert_sqlalchemy_type.register(column_type_eq(postgresql.UUID)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_utils.UUIDType)) +@convert_sqlalchemy_type.register(column_type_eq(uuid.UUID)) def convert_column_to_uuid( type_arg: Any, **kwargs, @@ -278,8 +278,8 @@ def convert_column_to_uuid( return graphene.UUID -@convert_sqlalchemy_type.register(value_equals(sqa_types.DateTime)) -@convert_sqlalchemy_type.register(value_equals(datetime.datetime)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_types.DateTime)) +@convert_sqlalchemy_type.register(column_type_eq(datetime.datetime)) def convert_column_to_datetime( type_arg: Any, **kwargs, @@ -287,8 +287,8 @@ def convert_column_to_datetime( return graphene.DateTime -@convert_sqlalchemy_type.register(value_equals(sqa_types.Time)) -@convert_sqlalchemy_type.register(value_equals(datetime.time)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_types.Time)) +@convert_sqlalchemy_type.register(column_type_eq(datetime.time)) def convert_column_to_time( type_arg: Any, **kwargs, @@ -296,8 +296,8 @@ def convert_column_to_time( return graphene.Time -@convert_sqlalchemy_type.register(value_equals(sqa_types.Date)) -@convert_sqlalchemy_type.register(value_equals(datetime.date)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_types.Date)) +@convert_sqlalchemy_type.register(column_type_eq(datetime.date)) def convert_column_to_date( type_arg: Any, **kwargs, @@ -305,9 +305,9 @@ def convert_column_to_date( return graphene.Date -@convert_sqlalchemy_type.register(value_equals(sqa_types.SmallInteger)) -@convert_sqlalchemy_type.register(value_equals(sqa_types.Integer)) -@convert_sqlalchemy_type.register(value_equals(int)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_types.SmallInteger)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_types.Integer)) +@convert_sqlalchemy_type.register(column_type_eq(int)) def convert_column_to_int_or_id( type_arg: Any, column: Optional[Union[MapperProperty, hybrid_property]] = None, @@ -319,8 +319,8 @@ def convert_column_to_int_or_id( ) # fixme drop the primary key processing in another pr -@convert_sqlalchemy_type.register(value_equals(sqa_types.Boolean)) -@convert_sqlalchemy_type.register(value_equals(bool)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_types.Boolean)) +@convert_sqlalchemy_type.register(column_type_eq(bool)) def convert_column_to_boolean( type_arg: Any, **kwargs, @@ -328,10 +328,10 @@ def convert_column_to_boolean( return graphene.Boolean -@convert_sqlalchemy_type.register(value_equals(float)) -@convert_sqlalchemy_type.register(value_equals(sqa_types.Float)) -@convert_sqlalchemy_type.register(value_equals(sqa_types.Numeric)) -@convert_sqlalchemy_type.register(value_equals(sqa_types.BigInteger)) +@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)) def convert_column_to_float( type_arg: Any, **kwargs, @@ -339,8 +339,8 @@ def convert_column_to_float( return graphene.Float -@convert_sqlalchemy_type.register(value_equals(postgresql.ENUM)) -@convert_sqlalchemy_type.register(value_equals(sqa_types.Enum)) +@convert_sqlalchemy_type.register(column_type_eq(postgresql.ENUM)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_types.Enum)) def convert_enum_to_enum( type_arg: Any, column: Optional[Union[MapperProperty, hybrid_property]] = None, @@ -354,7 +354,7 @@ def convert_enum_to_enum( # TODO Make ChoiceType conversion consistent with other enums -@convert_sqlalchemy_type.register(value_equals(sqa_utils.ChoiceType)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_utils.ChoiceType)) def convert_choice_to_enum( type_arg: sqa_utils.ChoiceType, column: Optional[Union[MapperProperty, hybrid_property]] = None, @@ -372,7 +372,7 @@ def convert_choice_to_enum( return graphene.Enum(name, column.type.choices) -@convert_sqlalchemy_type.register(value_equals(sqa_utils.ScalarListType)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_utils.ScalarListType)) def convert_scalar_list_to_list( type_arg: Any, **kwargs, @@ -388,8 +388,8 @@ def init_array_list_recursive(inner_type, n): ) -@convert_sqlalchemy_type.register(value_equals(sqa_types.ARRAY)) -@convert_sqlalchemy_type.register(value_equals(postgresql.ARRAY)) +@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, @@ -409,9 +409,9 @@ def convert_array_to_list( ) -@convert_sqlalchemy_type.register(value_equals(postgresql.HSTORE)) -@convert_sqlalchemy_type.register(value_equals(postgresql.JSON)) -@convert_sqlalchemy_type.register(value_equals(postgresql.JSONB)) +@convert_sqlalchemy_type.register(column_type_eq(postgresql.HSTORE)) +@convert_sqlalchemy_type.register(column_type_eq(postgresql.JSON)) +@convert_sqlalchemy_type.register(column_type_eq(postgresql.JSONB)) def convert_json_to_string( type_arg: Any, **kwargs, @@ -419,8 +419,8 @@ def convert_json_to_string( return JSONString -@convert_sqlalchemy_type.register(value_equals(sqa_utils.JSONType)) -@convert_sqlalchemy_type.register(value_equals(sqa_types.JSON)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_utils.JSONType)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_types.JSON)) def convert_json_type_to_string( type_arg: Any, **kwargs, @@ -428,7 +428,7 @@ def convert_json_type_to_string( return JSONString -@convert_sqlalchemy_type.register(value_equals(sqa_types.Variant)) +@convert_sqlalchemy_type.register(column_type_eq(sqa_types.Variant)) def convert_variant_to_impl_type( type_arg: sqa_types.Variant, column: Optional[Union[MapperProperty, hybrid_property]] = None, @@ -446,7 +446,7 @@ def convert_variant_to_impl_type( ) -@convert_sqlalchemy_type.register(value_equals(Decimal)) +@convert_sqlalchemy_type.register(column_type_eq(Decimal)) def convert_sqlalchemy_hybrid_property_type_decimal(type_arg: Any, **kwargs): # The reason Decimal should be serialized as a String is because this is a # base10 type used in things like money, and string allows it to not diff --git a/graphene_sqlalchemy/utils.py b/graphene_sqlalchemy/utils.py index 0f4d4c6..93290ca 100644 --- a/graphene_sqlalchemy/utils.py +++ b/graphene_sqlalchemy/utils.py @@ -203,7 +203,7 @@ def register(self, matcher_function: Callable[[Any], bool], func=None): return func -def value_equals(value: Any) -> Callable[[Any], bool]: +def column_type_eq(value: Any) -> Callable[[Any], bool]: """A simple function that makes the equality based matcher functions for SingleDispatchByMatchFunction prettier""" return lambda x: (x == value) From 524fc8c80fd6f5681697c02b2fd32dc871291c19 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Tue, 3 Jan 2023 12:22:32 +0100 Subject: [PATCH 06/12] tests: add tests for string fallback removal of hybrid property chore: change the exception types Signed-off-by: Erik Wrede --- graphene_sqlalchemy/converter.py | 13 ++++++++--- graphene_sqlalchemy/tests/models.py | 11 ++------- graphene_sqlalchemy/tests/test_converter.py | 26 ++++++++++++++++++++- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/graphene_sqlalchemy/converter.py b/graphene_sqlalchemy/converter.py index 4822f4d..de1bfde 100644 --- a/graphene_sqlalchemy/converter.py +++ b/graphene_sqlalchemy/converter.py @@ -247,8 +247,9 @@ def convert_sqlalchemy_type( return type_arg # No valid type found, warn and fall back to graphene.String - raise Exception( - "Don't know how to convert the SQLAlchemy field %s (%s, %s)" + raise TypeError( + "Don't know how to convert the SQLAlchemy field %s (%s, %s). " + "Please add a type converter or set the type manually using ORMField(type_=your_type)" % (column, column.__class__ or "no column provided", type_arg) ) @@ -562,6 +563,12 @@ def convert_sqlalchemy_hybrid_property_bare_str(type_arg: str, **kwargs): def convert_hybrid_property_return_type(hybrid_prop): # Grab the original method's return type annotations from inside the hybrid property - return_type_annotation = hybrid_prop.fget.__annotations__.get("return", str) + return_type_annotation = hybrid_prop.fget.__annotations__.get("return", None) + if not return_type_annotation: + raise TypeError( + "Cannot convert hybrid property type {} to a valid graphene type. " + "Please make sure to annotate the return type of the hybrid property or use the " + "type_ attribute of ORMField to set the type.".format(hybrid_prop) + ) return convert_sqlalchemy_type(return_type_annotation) diff --git a/graphene_sqlalchemy/tests/models.py b/graphene_sqlalchemy/tests/models.py index d02ee27..9531aaa 100644 --- a/graphene_sqlalchemy/tests/models.py +++ b/graphene_sqlalchemy/tests/models.py @@ -88,12 +88,12 @@ class Reporter(Base): favorite_article = relationship("Article", uselist=False, lazy="selectin") @hybrid_property - def hybrid_prop_with_doc(self): + def hybrid_prop_with_doc(self) -> str: """Docstring test""" return self.first_name @hybrid_property - def hybrid_prop(self): + def hybrid_prop(self) -> str: return self.first_name @hybrid_property @@ -253,13 +253,6 @@ def hybrid_prop_first_shopping_cart_item(self) -> ShoppingCartItem: def hybrid_prop_shopping_cart_item_list(self) -> List[ShoppingCartItem]: return [ShoppingCartItem(id=1), ShoppingCartItem(id=2)] - # Unsupported Type - # fixme move this somewhere else - # - # @hybrid_property - # def hybrid_prop_unsupported_type_tuple(self) -> Tuple[str, str]: - # return "this will actually", "be a string" - # Self-references @hybrid_property diff --git a/graphene_sqlalchemy/tests/test_converter.py b/graphene_sqlalchemy/tests/test_converter.py index 49a4d93..671c4f9 100644 --- a/graphene_sqlalchemy/tests/test_converter.py +++ b/graphene_sqlalchemy/tests/test_converter.py @@ -1,6 +1,6 @@ import enum import sys -from typing import Dict, Union +from typing import Dict, Tuple, Union import pytest import sqlalchemy_utils as sqa_utils @@ -79,6 +79,30 @@ def prop_method() -> int: assert get_hybrid_property_type(prop_method).type == graphene.Int +def test_hybrid_unknown_annotation(): + @hybrid_property + def hybrid_prop(self): + return "This should fail" + + with pytest.raises( + TypeError, + match=r"(.*)Please make sure to annotate the return type of the hybrid property or use the " + "type_ attribute of ORMField to set the type.(.*)", + ): + get_hybrid_property_type(hybrid_prop) + + +def test_hybrid_prop_no_type_annotation(): + @hybrid_property + def hybrid_prop(self) -> Tuple[str, str]: + return "This should Fail because", "we don't support Tuples in GQL" + + with pytest.raises( + TypeError, match=r"(.*)Don't know how to convert the SQLAlchemy field(.*)" + ): + get_hybrid_property_type(hybrid_prop) + + @pytest.mark.skipif( sys.version_info < (3, 10), reason="|-Style Unions are unsupported in python < 3.10" ) From c3ea36fcc446a76f31d974e4427e4b3a4828404b Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Tue, 3 Jan 2023 12:37:44 +0100 Subject: [PATCH 07/12] chore: refactor converter for object types and scalars Signed-off-by: Erik Wrede --- graphene_sqlalchemy/converter.py | 22 ++++++++++++++------- graphene_sqlalchemy/tests/test_converter.py | 19 ++++++++++++++++++ graphene_sqlalchemy/utils.py | 10 ++++++++++ 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/graphene_sqlalchemy/converter.py b/graphene_sqlalchemy/converter.py index de1bfde..f74f05f 100644 --- a/graphene_sqlalchemy/converter.py +++ b/graphene_sqlalchemy/converter.py @@ -23,6 +23,7 @@ column_type_eq, registry_sqlalchemy_model_from_str, safe_isinstance, + safe_issubclass, singledispatchbymatchfunction, ) @@ -236,16 +237,13 @@ def convert_sqlalchemy_type( registry: Registry = None, **kwargs, ): - existing_graphql_type = get_global_registry().get_type_for_model(type_arg) + registry_ = registry or get_global_registry() + # Enable returning Models by getting the corresponding model from the registry + # ToDo do we need a Dynamic Type here? + existing_graphql_type = registry_.get_type_for_model(type_arg) if existing_graphql_type: return existing_graphql_type - if isinstance(type_arg, type(graphene.ObjectType)): - return type_arg - - if isinstance(type_arg, type(graphene.Scalar)): - return type_arg - # No valid type found, warn and fall back to graphene.String raise TypeError( "Don't know how to convert the SQLAlchemy field %s (%s, %s). " @@ -254,6 +252,16 @@ def convert_sqlalchemy_type( ) +@convert_sqlalchemy_type.register(safe_issubclass(graphene.ObjectType)) +def convert_object_type(type_arg: Any, **kwargs): + return type_arg + + +@convert_sqlalchemy_type.register(safe_issubclass(graphene.Scalar)) +def convert_scalar_type(type_arg: Any, **kwargs): + return type_arg + + @convert_sqlalchemy_type.register(column_type_eq(str)) @convert_sqlalchemy_type.register(column_type_eq(sqa_types.String)) @convert_sqlalchemy_type.register(column_type_eq(sqa_types.Text)) diff --git a/graphene_sqlalchemy/tests/test_converter.py b/graphene_sqlalchemy/tests/test_converter.py index 671c4f9..346d963 100644 --- a/graphene_sqlalchemy/tests/test_converter.py +++ b/graphene_sqlalchemy/tests/test_converter.py @@ -103,6 +103,25 @@ def hybrid_prop(self) -> Tuple[str, str]: get_hybrid_property_type(hybrid_prop) +def test_hybrid_prop_object_type(): + class MyObjectType(graphene.ObjectType): + string = graphene.String() + + @hybrid_property + def hybrid_prop(self) -> MyObjectType: + return MyObjectType() + + assert get_hybrid_property_type(hybrid_prop).type == MyObjectType + + +def test_hybrid_prop_scalar_type(): + @hybrid_property + def hybrid_prop(self) -> graphene.String: + return "This should work" + + assert get_hybrid_property_type(hybrid_prop).type == graphene.String + + @pytest.mark.skipif( sys.version_info < (3, 10), reason="|-Style Unions are unsupported in python < 3.10" ) diff --git a/graphene_sqlalchemy/utils.py b/graphene_sqlalchemy/utils.py index 93290ca..1bf361f 100644 --- a/graphene_sqlalchemy/utils.py +++ b/graphene_sqlalchemy/utils.py @@ -219,6 +219,16 @@ def safe_isinstance_checker(arg): return safe_isinstance_checker +def safe_issubclass(cls): + def safe_issubclass_checker(arg): + try: + return issubclass(arg, cls) + except TypeError: + pass + + return safe_issubclass_checker + + def registry_sqlalchemy_model_from_str(model_name: str) -> Optional[Any]: from graphene_sqlalchemy.registry import get_global_registry From 9e6728a66b62b87f5103d1266d16915de5c7ceaf Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Tue, 3 Jan 2023 12:50:53 +0100 Subject: [PATCH 08/12] chore: remove string fallback from forward references Signed-off-by: Erik Wrede --- graphene_sqlalchemy/converter.py | 8 ++++++-- graphene_sqlalchemy/tests/test_converter.py | 16 ++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/graphene_sqlalchemy/converter.py b/graphene_sqlalchemy/converter.py index f74f05f..a660d30 100644 --- a/graphene_sqlalchemy/converter.py +++ b/graphene_sqlalchemy/converter.py @@ -553,7 +553,11 @@ def convert_sqlalchemy_hybrid_property_forwardref(type_arg: Any, **kwargs): def forward_reference_solver(): model = registry_sqlalchemy_model_from_str(type_arg.__forward_arg__) if not model: - return graphene.String + raise TypeError( + "No model found in Registry for forward reference for type %s. " + "Only forward references to other SQLAlchemy Models mapped to " + "SQLAlchemyObjectTypes are allowed." % type_arg + ) # Always fall back to string if no ForwardRef type found. return get_global_registry().get_type_for_model(model) @@ -579,4 +583,4 @@ def convert_hybrid_property_return_type(hybrid_prop): "type_ attribute of ORMField to set the type.".format(hybrid_prop) ) - return convert_sqlalchemy_type(return_type_annotation) + return convert_sqlalchemy_type(return_type_annotation, column=hybrid_prop) diff --git a/graphene_sqlalchemy/tests/test_converter.py b/graphene_sqlalchemy/tests/test_converter.py index 346d963..2302bf3 100644 --- a/graphene_sqlalchemy/tests/test_converter.py +++ b/graphene_sqlalchemy/tests/test_converter.py @@ -103,6 +103,22 @@ def hybrid_prop(self) -> Tuple[str, str]: get_hybrid_property_type(hybrid_prop) +def test_hybrid_invalid_forward_reference(): + class MyTypeNotInRegistry: + pass + + @hybrid_property + def hybrid_prop(self) -> "MyTypeNotInRegistry": + return MyTypeNotInRegistry() + + with pytest.raises( + TypeError, + match=r"(.*)Only forward references to other SQLAlchemy Models mapped to " + "SQLAlchemyObjectTypes are allowed.(.*)", + ): + get_hybrid_property_type(hybrid_prop).type + + def test_hybrid_prop_object_type(): class MyObjectType(graphene.ObjectType): string = graphene.String() From abbeecec9ac7e6716e72837da68a723ee3a8f0fe Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Tue, 3 Jan 2023 12:54:22 +0100 Subject: [PATCH 09/12] chore: adjust comment Signed-off-by: Erik Wrede --- graphene_sqlalchemy/converter.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/graphene_sqlalchemy/converter.py b/graphene_sqlalchemy/converter.py index a660d30..c4c454b 100644 --- a/graphene_sqlalchemy/converter.py +++ b/graphene_sqlalchemy/converter.py @@ -239,7 +239,8 @@ def convert_sqlalchemy_type( ): registry_ = registry or get_global_registry() # Enable returning Models by getting the corresponding model from the registry - # ToDo do we need a Dynamic Type here? + # ToDo Move to a separate converter later, matching all sqlalchemy mapped + # types and returning a dynamic existing_graphql_type = registry_.get_type_for_model(type_arg) if existing_graphql_type: return existing_graphql_type From b68ef9161d6aafa116f6f2781c08eff8b44249ec Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Tue, 3 Jan 2023 13:11:32 +0100 Subject: [PATCH 10/12] fix: fix regression on id types from last commit Signed-off-by: Erik Wrede --- graphene_sqlalchemy/converter.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/graphene_sqlalchemy/converter.py b/graphene_sqlalchemy/converter.py index c4c454b..74c9892 100644 --- a/graphene_sqlalchemy/converter.py +++ b/graphene_sqlalchemy/converter.py @@ -324,9 +324,11 @@ def convert_column_to_int_or_id( registry: Registry = None, **kwargs, ): - return ( - graphene.ID if (column is not None) and column.primary_key else graphene.Int - ) # fixme drop the primary key processing in another pr + # fixme drop the primary key processing from here in another pr + if column is not None: + if getattr(column, "primary_key", False) is True: + return graphene.ID + return graphene.Int @convert_sqlalchemy_type.register(column_type_eq(sqa_types.Boolean)) From 7e1ab40894702493a67ea81d824fa2bb45c04873 Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Tue, 3 Jan 2023 20:23:31 +0100 Subject: [PATCH 11/12] refactor: made registry calls in converters lazy Signed-off-by: Erik Wrede --- graphene_sqlalchemy/converter.py | 36 ++++++++++++----- graphene_sqlalchemy/tests/test_converter.py | 45 +++++++++++++++++++++ 2 files changed, 70 insertions(+), 11 deletions(-) diff --git a/graphene_sqlalchemy/converter.py b/graphene_sqlalchemy/converter.py index 74c9892..6f1395c 100644 --- a/graphene_sqlalchemy/converter.py +++ b/graphene_sqlalchemy/converter.py @@ -8,7 +8,7 @@ from sqlalchemy import types as sqa_types from sqlalchemy.dialects import postgresql from sqlalchemy.ext.hybrid import hybrid_property -from sqlalchemy.orm import interfaces, strategies +from sqlalchemy.orm import DeclarativeMeta, interfaces, strategies import graphene from graphene.types.json import JSONString @@ -231,21 +231,15 @@ def convert_sqlalchemy_column(column_prop, registry, resolver, **field_kwargs): @singledispatchbymatchfunction -def convert_sqlalchemy_type( +def convert_sqlalchemy_type( # noqa type_arg: Any, column: Optional[Union[MapperProperty, hybrid_property]] = None, registry: Registry = None, **kwargs, ): - registry_ = registry or get_global_registry() - # Enable returning Models by getting the corresponding model from the registry - # ToDo Move to a separate converter later, matching all sqlalchemy mapped - # types and returning a dynamic - existing_graphql_type = registry_.get_type_for_model(type_arg) - if existing_graphql_type: - return existing_graphql_type - - # No valid type found, warn and fall back to graphene.String + + # No valid type found, raise an error + raise TypeError( "Don't know how to convert the SQLAlchemy field %s (%s, %s). " "Please add a type converter or set the type manually using ORMField(type_=your_type)" @@ -253,6 +247,26 @@ def convert_sqlalchemy_type( ) +@convert_sqlalchemy_type.register(safe_isinstance(DeclarativeMeta)) +def convert_sqlalchemy_model_using_registry( + type_arg: Any, registry: Registry = None, **kwargs +): + registry_ = registry or get_global_registry() + + def get_type_from_registry(): + existing_graphql_type = registry_.get_type_for_model(type_arg) + if existing_graphql_type: + return existing_graphql_type + + raise TypeError( + "No model found in Registry for type %s. " + "Only references to SQLAlchemy Models mapped to " + "SQLAlchemyObjectTypes are allowed." % type_arg + ) + + return get_type_from_registry() + + @convert_sqlalchemy_type.register(safe_issubclass(graphene.ObjectType)) def convert_object_type(type_arg: Any, **kwargs): return type_arg diff --git a/graphene_sqlalchemy/tests/test_converter.py b/graphene_sqlalchemy/tests/test_converter.py index 2302bf3..e903396 100644 --- a/graphene_sqlalchemy/tests/test_converter.py +++ b/graphene_sqlalchemy/tests/test_converter.py @@ -138,6 +138,51 @@ def hybrid_prop(self) -> graphene.String: assert get_hybrid_property_type(hybrid_prop).type == graphene.String +def test_hybrid_prop_not_mapped_to_graphene_type(): + @hybrid_property + def hybrid_prop(self) -> ShoppingCartItem: + return "This shouldn't work" + + with pytest.raises(TypeError, match=r"(.*)No model found in Registry for type(.*)"): + get_hybrid_property_type(hybrid_prop).type + + +def test_hybrid_prop_mapped_to_graphene_type(): + class ShoppingCartType(SQLAlchemyObjectType): + class Meta: + model = ShoppingCartItem + + @hybrid_property + def hybrid_prop(self) -> ShoppingCartItem: + return "Dummy return value" + + get_hybrid_property_type(hybrid_prop).type == ShoppingCartType + + +def test_hybrid_prop_forward_ref_not_mapped_to_graphene_type(): + @hybrid_property + def hybrid_prop(self) -> "ShoppingCartItem": + return "This shouldn't work" + + with pytest.raises( + TypeError, + match=r"(.*)No model found in Registry for forward reference for type(.*)", + ): + get_hybrid_property_type(hybrid_prop).type + + +def test_hybrid_prop_forward_ref_mapped_to_graphene_type(): + class ShoppingCartType(SQLAlchemyObjectType): + class Meta: + model = ShoppingCartItem + + @hybrid_property + def hybrid_prop(self) -> "ShoppingCartItem": + return "Dummy return value" + + get_hybrid_property_type(hybrid_prop).type == ShoppingCartType + + @pytest.mark.skipif( sys.version_info < (3, 10), reason="|-Style Unions are unsupported in python < 3.10" ) From 9756cee6b2d1a5f43e1485484e9f08dd2aea14cf Mon Sep 17 00:00:00 2001 From: Erik Wrede Date: Tue, 3 Jan 2023 20:55:20 +0100 Subject: [PATCH 12/12] fix: DeclarativeMeta import path adjusted for sqa<1.4 Signed-off-by: Erik Wrede --- graphene_sqlalchemy/converter.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/graphene_sqlalchemy/converter.py b/graphene_sqlalchemy/converter.py index 6f1395c..7c5330b 100644 --- a/graphene_sqlalchemy/converter.py +++ b/graphene_sqlalchemy/converter.py @@ -8,7 +8,7 @@ from sqlalchemy import types as sqa_types from sqlalchemy.dialects import postgresql from sqlalchemy.ext.hybrid import hybrid_property -from sqlalchemy.orm import DeclarativeMeta, interfaces, strategies +from sqlalchemy.orm import interfaces, strategies import graphene from graphene.types.json import JSONString @@ -19,6 +19,7 @@ from .registry import Registry, get_global_registry from .resolvers import get_attr_resolver, get_custom_resolver from .utils import ( + SQL_VERSION_HIGHER_EQUAL_THAN_1_4, DummyImport, column_type_eq, registry_sqlalchemy_model_from_str, @@ -27,6 +28,12 @@ singledispatchbymatchfunction, ) +# Import path changed in 1.4 +if SQL_VERSION_HIGHER_EQUAL_THAN_1_4: + from sqlalchemy.orm import DeclarativeMeta +else: + from sqlalchemy.ext.declarative import DeclarativeMeta + # We just use MapperProperties for type hints, they don't exist in sqlalchemy < 1.4 try: from sqlalchemy import MapperProperty @@ -237,7 +244,6 @@ def convert_sqlalchemy_type( # noqa registry: Registry = None, **kwargs, ): - # No valid type found, raise an error raise TypeError(