Skip to content

Commit 0820da7

Browse files
authored
Support setting @hybrid_property's return type from the functions type annotations. (#340)
Adds support for automatic type conversion for @hybrid_property's using converters similar to @convert_sqlalchemy_type.register(). Currently, all basic types and (nested) Lists are supported. This feature replaces the old default string conversion. String conversion is still used as a fallback in case no compatible converter was found to ensure backward compatibility. Thank you @conao3 & @flipbit03!
1 parent 5da2048 commit 0820da7

File tree

5 files changed

+438
-18
lines changed

5 files changed

+438
-18
lines changed

Diff for: graphene_sqlalchemy/converter.py

+118-6
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
1+
import datetime
2+
import typing
3+
import warnings
4+
from decimal import Decimal
15
from functools import singledispatch
6+
from typing import Any
27

38
from sqlalchemy import types
49
from sqlalchemy.dialects import postgresql
510
from sqlalchemy.orm import interfaces, strategies
611

7-
from graphene import (ID, Boolean, Dynamic, Enum, Field, Float, Int, List,
8-
String)
12+
from graphene import (ID, Boolean, Date, DateTime, Dynamic, Enum, Field, Float,
13+
Int, List, String, Time)
914
from graphene.types.json import JSONString
1015

1116
from .batching import get_batch_resolver
@@ -14,6 +19,14 @@
1419
default_connection_field_factory)
1520
from .registry import get_global_registry
1621
from .resolvers import get_attr_resolver, get_custom_resolver
22+
from .utils import (registry_sqlalchemy_model_from_str, safe_isinstance,
23+
singledispatchbymatchfunction, value_equals)
24+
25+
try:
26+
from typing import ForwardRef
27+
except ImportError:
28+
# python 3.6
29+
from typing import _ForwardRef as ForwardRef
1730

1831
try:
1932
from sqlalchemy_utils import ChoiceType, JSONType, ScalarListType, TSVectorType
@@ -25,7 +38,6 @@
2538
except ImportError:
2639
EnumTypeImpl = object
2740

28-
2941
is_selectin_available = getattr(strategies, 'SelectInLoader', None)
3042

3143

@@ -48,6 +60,7 @@ def convert_sqlalchemy_relationship(relationship_prop, obj_type, connection_fiel
4860
:param dict field_kwargs:
4961
:rtype: Dynamic
5062
"""
63+
5164
def dynamic_type():
5265
""":rtype: Field|None"""
5366
direction = relationship_prop.direction
@@ -115,8 +128,7 @@ def _convert_o2m_or_m2m_relationship(relationship_prop, obj_type, batching, conn
115128

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

121133
return Field(
122134
resolver=resolver,
@@ -240,7 +252,7 @@ def convert_scalar_list_to_list(type, column, registry=None):
240252

241253

242254
def init_array_list_recursive(inner_type, n):
243-
return inner_type if n == 0 else List(init_array_list_recursive(inner_type, n-1))
255+
return inner_type if n == 0 else List(init_array_list_recursive(inner_type, n - 1))
244256

245257

246258
@convert_sqlalchemy_type.register(types.ARRAY)
@@ -260,3 +272,103 @@ def convert_json_to_string(type, column, registry=None):
260272
@convert_sqlalchemy_type.register(JSONType)
261273
def convert_json_type_to_string(type, column, registry=None):
262274
return JSONString
275+
276+
277+
@singledispatchbymatchfunction
278+
def convert_sqlalchemy_hybrid_property_type(arg: Any):
279+
existing_graphql_type = get_global_registry().get_type_for_model(arg)
280+
if existing_graphql_type:
281+
return existing_graphql_type
282+
283+
# No valid type found, warn and fall back to graphene.String
284+
warnings.warn(
285+
(f"I don't know how to generate a GraphQL type out of a \"{arg}\" type."
286+
"Falling back to \"graphene.String\"")
287+
)
288+
return String
289+
290+
291+
@convert_sqlalchemy_hybrid_property_type.register(value_equals(str))
292+
def convert_sqlalchemy_hybrid_property_type_str(arg):
293+
return String
294+
295+
296+
@convert_sqlalchemy_hybrid_property_type.register(value_equals(int))
297+
def convert_sqlalchemy_hybrid_property_type_int(arg):
298+
return Int
299+
300+
301+
@convert_sqlalchemy_hybrid_property_type.register(value_equals(float))
302+
def convert_sqlalchemy_hybrid_property_type_float(arg):
303+
return Float
304+
305+
306+
@convert_sqlalchemy_hybrid_property_type.register(value_equals(Decimal))
307+
def convert_sqlalchemy_hybrid_property_type_decimal(arg):
308+
# The reason Decimal should be serialized as a String is because this is a
309+
# base10 type used in things like money, and string allows it to not
310+
# lose precision (which would happen if we downcasted to a Float, for example)
311+
return String
312+
313+
314+
@convert_sqlalchemy_hybrid_property_type.register(value_equals(bool))
315+
def convert_sqlalchemy_hybrid_property_type_bool(arg):
316+
return Boolean
317+
318+
319+
@convert_sqlalchemy_hybrid_property_type.register(value_equals(datetime.datetime))
320+
def convert_sqlalchemy_hybrid_property_type_datetime(arg):
321+
return DateTime
322+
323+
324+
@convert_sqlalchemy_hybrid_property_type.register(value_equals(datetime.date))
325+
def convert_sqlalchemy_hybrid_property_type_date(arg):
326+
return Date
327+
328+
329+
@convert_sqlalchemy_hybrid_property_type.register(value_equals(datetime.time))
330+
def convert_sqlalchemy_hybrid_property_type_time(arg):
331+
return Time
332+
333+
334+
@convert_sqlalchemy_hybrid_property_type.register(lambda x: getattr(x, '__origin__', None) in [list, typing.List])
335+
def convert_sqlalchemy_hybrid_property_type_list_t(arg):
336+
# type is either list[T] or List[T], generic argument at __args__[0]
337+
internal_type = arg.__args__[0]
338+
339+
graphql_internal_type = convert_sqlalchemy_hybrid_property_type(internal_type)
340+
341+
return List(graphql_internal_type)
342+
343+
344+
@convert_sqlalchemy_hybrid_property_type.register(safe_isinstance(ForwardRef))
345+
def convert_sqlalchemy_hybrid_property_forwardref(arg):
346+
"""
347+
Generate a lambda that will resolve the type at runtime
348+
This takes care of self-references
349+
"""
350+
351+
def forward_reference_solver():
352+
model = registry_sqlalchemy_model_from_str(arg.__forward_arg__)
353+
if not model:
354+
return String
355+
# Always fall back to string if no ForwardRef type found.
356+
return get_global_registry().get_type_for_model(model)
357+
358+
return forward_reference_solver
359+
360+
361+
@convert_sqlalchemy_hybrid_property_type.register(safe_isinstance(str))
362+
def convert_sqlalchemy_hybrid_property_bare_str(arg):
363+
"""
364+
Convert Bare String into a ForwardRef
365+
"""
366+
367+
return convert_sqlalchemy_hybrid_property_type(ForwardRef(arg))
368+
369+
370+
def convert_hybrid_property_return_type(hybrid_prop):
371+
# Grab the original method's return type annotations from inside the hybrid property
372+
return_type_annotation = hybrid_prop.fget.__annotations__.get('return', str)
373+
374+
return convert_sqlalchemy_hybrid_property_type(return_type_annotation)

Diff for: graphene_sqlalchemy/tests/models.py

+122
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from __future__ import absolute_import
22

3+
import datetime
34
import enum
5+
from decimal import Decimal
6+
from typing import List, Tuple
47

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

75+
@hybrid_property
76+
def hybrid_prop_str(self) -> str:
77+
return self.first_name
78+
79+
@hybrid_property
80+
def hybrid_prop_int(self) -> int:
81+
return 42
82+
83+
@hybrid_property
84+
def hybrid_prop_float(self) -> float:
85+
return 42.3
86+
87+
@hybrid_property
88+
def hybrid_prop_bool(self) -> bool:
89+
return True
90+
91+
@hybrid_property
92+
def hybrid_prop_list(self) -> List[int]:
93+
return [1, 2, 3]
94+
7295
column_prop = column_property(
7396
select([func.cast(func.count(id), Integer)]), doc="Column property"
7497
)
@@ -95,3 +118,102 @@ def __subclasses__(cls):
95118
editor_table = Table("editors", Base.metadata, autoload=True)
96119

97120
mapper(ReflectedEditor, editor_table)
121+
122+
123+
############################################
124+
# The models below are mainly used in the
125+
# @hybrid_property type inference scenarios
126+
############################################
127+
128+
129+
class ShoppingCartItem(Base):
130+
__tablename__ = "shopping_cart_items"
131+
132+
id = Column(Integer(), primary_key=True)
133+
134+
@hybrid_property
135+
def hybrid_prop_shopping_cart(self) -> List['ShoppingCart']:
136+
return [ShoppingCart(id=1)]
137+
138+
139+
class ShoppingCart(Base):
140+
__tablename__ = "shopping_carts"
141+
142+
id = Column(Integer(), primary_key=True)
143+
144+
# Standard Library types
145+
146+
@hybrid_property
147+
def hybrid_prop_str(self) -> str:
148+
return self.first_name
149+
150+
@hybrid_property
151+
def hybrid_prop_int(self) -> int:
152+
return 42
153+
154+
@hybrid_property
155+
def hybrid_prop_float(self) -> float:
156+
return 42.3
157+
158+
@hybrid_property
159+
def hybrid_prop_bool(self) -> bool:
160+
return True
161+
162+
@hybrid_property
163+
def hybrid_prop_decimal(self) -> Decimal:
164+
return Decimal("3.14")
165+
166+
@hybrid_property
167+
def hybrid_prop_date(self) -> datetime.date:
168+
return datetime.datetime.now().date()
169+
170+
@hybrid_property
171+
def hybrid_prop_time(self) -> datetime.time:
172+
return datetime.datetime.now().time()
173+
174+
@hybrid_property
175+
def hybrid_prop_datetime(self) -> datetime.datetime:
176+
return datetime.datetime.now()
177+
178+
# Lists and Nested Lists
179+
180+
@hybrid_property
181+
def hybrid_prop_list_int(self) -> List[int]:
182+
return [1, 2, 3]
183+
184+
@hybrid_property
185+
def hybrid_prop_list_date(self) -> List[datetime.date]:
186+
return [self.hybrid_prop_date, self.hybrid_prop_date, self.hybrid_prop_date]
187+
188+
@hybrid_property
189+
def hybrid_prop_nested_list_int(self) -> List[List[int]]:
190+
return [self.hybrid_prop_list_int, ]
191+
192+
@hybrid_property
193+
def hybrid_prop_deeply_nested_list_int(self) -> List[List[List[int]]]:
194+
return [[self.hybrid_prop_list_int, ], ]
195+
196+
# Other SQLAlchemy Instances
197+
@hybrid_property
198+
def hybrid_prop_first_shopping_cart_item(self) -> ShoppingCartItem:
199+
return ShoppingCartItem(id=1)
200+
201+
# Other SQLAlchemy Instances
202+
@hybrid_property
203+
def hybrid_prop_shopping_cart_item_list(self) -> List[ShoppingCartItem]:
204+
return [ShoppingCartItem(id=1), ShoppingCartItem(id=2)]
205+
206+
# Unsupported Type
207+
@hybrid_property
208+
def hybrid_prop_unsupported_type_tuple(self) -> Tuple[str, str]:
209+
return "this will actually", "be a string"
210+
211+
# Self-references
212+
213+
@hybrid_property
214+
def hybrid_prop_self_referential(self) -> 'ShoppingCart':
215+
return ShoppingCart(id=1)
216+
217+
@hybrid_property
218+
def hybrid_prop_self_referential_list(self) -> List['ShoppingCart']:
219+
return [ShoppingCart(id=1)]

0 commit comments

Comments
 (0)