Skip to content

Support setting @hybrid_property's return type from the functions type annotations. #340

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

Merged
merged 19 commits into from
Apr 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
0cb60ad
Get default type of hybrid_property from type annotation
conao3 Mar 4, 2022
c6089e1
use type_ instead of type
conao3 Mar 5, 2022
1d754e0
In modern python, instead of a class, it contains class name
conao3 Mar 5, 2022
8f6fad8
show warnings to inform user that type convertion has failed
conao3 Mar 5, 2022
95b7012
Implemented matcher-based @singledispatch lookalike :-)
flipbit03 Apr 29, 2022
8cf39c1
Implemented type generation out of hybrid_property method's type anno…
flipbit03 Apr 29, 2022
3216608
Fixed tests (phew!!!)
flipbit03 Apr 29, 2022
a7e8a1b
Made tests also work in py36 (pheww!!! #2)
flipbit03 Apr 29, 2022
d83f7e1
Support Decimal() type.
flipbit03 Apr 29, 2022
6f6c61c
Fallback to String / Implemented Enum support.
flipbit03 Apr 29, 2022
3f3593e
better naming conventions (suggested by @erikwrede)
flipbit03 Apr 29, 2022
ac6c4bc
Reverted all tests. We will be writing new tests specifically for the…
flipbit03 Apr 29, 2022
44f0e62
removed unused function
flipbit03 Apr 29, 2022
95323f5
Tests: Fix failing tests and ensure **stable ordering** of property c…
flipbit03 Apr 29, 2022
c57eada
New test and models specific to hybrid_property type inference tests.
flipbit03 Apr 29, 2022
6c53351
Add a test for an unsupported type (Tuple), and test it coerces back …
flipbit03 Apr 29, 2022
a17782b
Lint.
flipbit03 Apr 29, 2022
687e96e
Self-referential SQLAlchemy Model Support.
flipbit03 Apr 29, 2022
3acd92b
Extra Tests for Self-referential SQLAlchemy Model Support (in special…
flipbit03 Apr 29, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 118 additions & 6 deletions graphene_sqlalchemy/converter.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,16 @@
import datetime
import typing
import warnings
from decimal import Decimal
from functools import singledispatch
from typing import Any

from sqlalchemy import types
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import interfaces, strategies

from graphene import (ID, Boolean, Dynamic, Enum, Field, Float, Int, List,
String)
from graphene import (ID, Boolean, Date, DateTime, Dynamic, Enum, Field, Float,
Int, List, String, Time)
from graphene.types.json import JSONString

from .batching import get_batch_resolver
Expand All @@ -14,6 +19,14 @@
default_connection_field_factory)
from .registry import get_global_registry
from .resolvers import get_attr_resolver, get_custom_resolver
from .utils import (registry_sqlalchemy_model_from_str, safe_isinstance,
singledispatchbymatchfunction, value_equals)

try:
from typing import ForwardRef
except ImportError:
# python 3.6
from typing import _ForwardRef as ForwardRef

try:
from sqlalchemy_utils import ChoiceType, JSONType, ScalarListType, TSVectorType
Expand All @@ -25,7 +38,6 @@
except ImportError:
EnumTypeImpl = object


is_selectin_available = getattr(strategies, 'SelectInLoader', None)


Expand All @@ -48,6 +60,7 @@ def convert_sqlalchemy_relationship(relationship_prop, obj_type, connection_fiel
:param dict field_kwargs:
:rtype: Dynamic
"""

def dynamic_type():
""":rtype: Field|None"""
direction = relationship_prop.direction
Expand Down Expand Up @@ -115,8 +128,7 @@ def _convert_o2m_or_m2m_relationship(relationship_prop, obj_type, batching, conn

def convert_sqlalchemy_hybrid_method(hybrid_prop, resolver, **field_kwargs):
if 'type_' not in field_kwargs:
# TODO The default type should be dependent on the type of the property propety.
field_kwargs['type_'] = String
field_kwargs['type_'] = convert_hybrid_property_return_type(hybrid_prop)

return Field(
resolver=resolver,
Expand Down Expand Up @@ -240,7 +252,7 @@ def convert_scalar_list_to_list(type, column, registry=None):


def init_array_list_recursive(inner_type, n):
return inner_type if n == 0 else List(init_array_list_recursive(inner_type, n-1))
return inner_type if n == 0 else List(init_array_list_recursive(inner_type, n - 1))


@convert_sqlalchemy_type.register(types.ARRAY)
Expand All @@ -260,3 +272,103 @@ def convert_json_to_string(type, column, registry=None):
@convert_sqlalchemy_type.register(JSONType)
def convert_json_type_to_string(type, column, registry=None):
return JSONString


@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

# 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\"")
)
return String


@convert_sqlalchemy_hybrid_property_type.register(value_equals(str))
def convert_sqlalchemy_hybrid_property_type_str(arg):
return String


@convert_sqlalchemy_hybrid_property_type.register(value_equals(int))
def convert_sqlalchemy_hybrid_property_type_int(arg):
return Int


@convert_sqlalchemy_hybrid_property_type.register(value_equals(float))
def convert_sqlalchemy_hybrid_property_type_float(arg):
return Float


@convert_sqlalchemy_hybrid_property_type.register(value_equals(Decimal))
def convert_sqlalchemy_hybrid_property_type_decimal(arg):
# 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 String


@convert_sqlalchemy_hybrid_property_type.register(value_equals(bool))
def convert_sqlalchemy_hybrid_property_type_bool(arg):
return Boolean


@convert_sqlalchemy_hybrid_property_type.register(value_equals(datetime.datetime))
def convert_sqlalchemy_hybrid_property_type_datetime(arg):
return DateTime


@convert_sqlalchemy_hybrid_property_type.register(value_equals(datetime.date))
def convert_sqlalchemy_hybrid_property_type_date(arg):
return Date


@convert_sqlalchemy_hybrid_property_type.register(value_equals(datetime.time))
def convert_sqlalchemy_hybrid_property_type_time(arg):
return Time


@convert_sqlalchemy_hybrid_property_type.register(lambda x: getattr(x, '__origin__', None) in [list, typing.List])
def convert_sqlalchemy_hybrid_property_type_list_t(arg):
# type is either list[T] or List[T], generic argument at __args__[0]
internal_type = arg.__args__[0]

graphql_internal_type = convert_sqlalchemy_hybrid_property_type(internal_type)

return List(graphql_internal_type)


@convert_sqlalchemy_hybrid_property_type.register(safe_isinstance(ForwardRef))
def convert_sqlalchemy_hybrid_property_forwardref(arg):
"""
Generate a lambda that will resolve the type at runtime
This takes care of self-references
"""

def forward_reference_solver():
model = registry_sqlalchemy_model_from_str(arg.__forward_arg__)
if not model:
return String
# Always fall back to string if no ForwardRef type found.
return get_global_registry().get_type_for_model(model)

return forward_reference_solver


@convert_sqlalchemy_hybrid_property_type.register(safe_isinstance(str))
def convert_sqlalchemy_hybrid_property_bare_str(arg):
"""
Convert Bare String into a ForwardRef
"""

return convert_sqlalchemy_hybrid_property_type(ForwardRef(arg))


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)
122 changes: 122 additions & 0 deletions graphene_sqlalchemy/tests/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
from __future__ import absolute_import

import datetime
import enum
from decimal import Decimal
from typing import List, Tuple

from sqlalchemy import (Column, Date, Enum, ForeignKey, Integer, String, Table,
func, select)
Expand Down Expand Up @@ -69,6 +72,26 @@ class Reporter(Base):
def hybrid_prop(self):
return self.first_name

@hybrid_property
def hybrid_prop_str(self) -> str:
return self.first_name

@hybrid_property
def hybrid_prop_int(self) -> int:
return 42

@hybrid_property
def hybrid_prop_float(self) -> float:
return 42.3

@hybrid_property
def hybrid_prop_bool(self) -> bool:
return True

@hybrid_property
def hybrid_prop_list(self) -> List[int]:
return [1, 2, 3]

column_prop = column_property(
select([func.cast(func.count(id), Integer)]), doc="Column property"
)
Expand All @@ -95,3 +118,102 @@ def __subclasses__(cls):
editor_table = Table("editors", Base.metadata, autoload=True)

mapper(ReflectedEditor, editor_table)


############################################
# The models below are mainly used in the
# @hybrid_property type inference scenarios
############################################


class ShoppingCartItem(Base):
__tablename__ = "shopping_cart_items"

id = Column(Integer(), primary_key=True)

@hybrid_property
def hybrid_prop_shopping_cart(self) -> List['ShoppingCart']:
return [ShoppingCart(id=1)]


class ShoppingCart(Base):
__tablename__ = "shopping_carts"

id = Column(Integer(), primary_key=True)

# Standard Library types

@hybrid_property
def hybrid_prop_str(self) -> str:
return self.first_name

@hybrid_property
def hybrid_prop_int(self) -> int:
return 42

@hybrid_property
def hybrid_prop_float(self) -> float:
return 42.3

@hybrid_property
def hybrid_prop_bool(self) -> bool:
return True

@hybrid_property
def hybrid_prop_decimal(self) -> Decimal:
return Decimal("3.14")

@hybrid_property
def hybrid_prop_date(self) -> datetime.date:
return datetime.datetime.now().date()

@hybrid_property
def hybrid_prop_time(self) -> datetime.time:
return datetime.datetime.now().time()

@hybrid_property
def hybrid_prop_datetime(self) -> datetime.datetime:
return datetime.datetime.now()

# Lists and Nested Lists

@hybrid_property
def hybrid_prop_list_int(self) -> List[int]:
return [1, 2, 3]

@hybrid_property
def hybrid_prop_list_date(self) -> List[datetime.date]:
return [self.hybrid_prop_date, self.hybrid_prop_date, self.hybrid_prop_date]

@hybrid_property
def hybrid_prop_nested_list_int(self) -> List[List[int]]:
return [self.hybrid_prop_list_int, ]

@hybrid_property
def hybrid_prop_deeply_nested_list_int(self) -> List[List[List[int]]]:
return [[self.hybrid_prop_list_int, ], ]

# Other SQLAlchemy Instances
@hybrid_property
def hybrid_prop_first_shopping_cart_item(self) -> ShoppingCartItem:
return ShoppingCartItem(id=1)

# Other SQLAlchemy Instances
@hybrid_property
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"

# Self-references

@hybrid_property
def hybrid_prop_self_referential(self) -> 'ShoppingCart':
return ShoppingCart(id=1)

@hybrid_property
def hybrid_prop_self_referential_list(self) -> List['ShoppingCart']:
return [ShoppingCart(id=1)]
Loading