Skip to content

Commit 38edf85

Browse files
author
Rami Chowdhury
committed
Flesh out and add tests & docs for forward refs
1 parent 424670f commit 38edf85

File tree

5 files changed

+269
-27
lines changed

5 files changed

+269
-27
lines changed

README.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,28 @@ query = '''
6161
result = schema.execute(query)
6262
```
6363

64+
### Forward declarations and circular references
65+
66+
`graphene_pydantic` supports forward declarations and circular references, but you will need to call the `resolve_placeholders()` method to ensure the types are fully updated before you execute a GraphQL query. For instance:
67+
68+
``` python
69+
class FooModel(BaseModel):
70+
bar: 'BarModel'
71+
72+
class BarModel(BaseModel):
73+
foo: FooModel
74+
75+
class Foo(PydanticObjectType):
76+
class Meta:
77+
model = FooModel
78+
79+
class Bar(PydanticObjectType):
80+
class Meta:
81+
model = BarModel
82+
83+
Foo.resolve_placeholders() # <-- this line ensures it's resolvable in GraphQL
84+
Bar.resolve_placeholders()
85+
```
6486

6587
### Full Examples
6688

graphene_pydantic/converters.py

Lines changed: 88 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
1+
import sys
12
import collections
2-
from collections import abc
3+
import collections.abc
34
import typing as T
45
import uuid
56
import datetime
67
import decimal
78
import enum
89

10+
from pydantic import BaseModel, fields
11+
912
from graphene import Field, Boolean, Enum, Float, Int, List, String, UUID, Union
1013
from graphene.types.base import BaseType
14+
from graphene.types.datetime import Date, Time, DateTime
1115

1216
try:
1317
from graphene.types.decimal import Decimal as GrapheneDecimal
@@ -17,9 +21,6 @@
1721
# graphene 2.1.5+ is required for Decimals
1822
DECIMAL_SUPPORTED = False
1923

20-
from graphene.types.datetime import Date, Time, DateTime
21-
from pydantic import fields
22-
2324
from .registry import Registry
2425
from .util import construct_union_class_name
2526

@@ -44,15 +45,22 @@ def _get_field(root, _info):
4445

4546

4647
def convert_pydantic_field(
47-
field: fields.Field, registry: Registry, **field_kwargs
48+
field: fields.Field,
49+
registry: Registry,
50+
parent_type: T.Type = None,
51+
model: T.Type[BaseModel] = None,
52+
**field_kwargs,
4853
) -> Field:
4954
"""
5055
Convert a Pydantic model field into a Graphene type field that we can add
5156
to the generated Graphene data model type.
5257
"""
5358
declared_type = getattr(field, "type_", None)
5459
field_kwargs.setdefault(
55-
"type", convert_pydantic_type(declared_type, field, registry)
60+
"type",
61+
convert_pydantic_type(
62+
declared_type, field, registry, parent_type=parent_type, model=model
63+
),
5664
)
5765
field_kwargs.setdefault("required", field.required)
5866
field_kwargs.setdefault("default_value", field.default)
@@ -66,14 +74,20 @@ def convert_pydantic_field(
6674

6775

6876
def convert_pydantic_type(
69-
type_: T.Type, field: fields.Field, registry: Registry = None
77+
type_: T.Type,
78+
field: fields.Field,
79+
registry: Registry = None,
80+
parent_type: T.Type = None,
81+
model: T.Type[BaseModel] = None,
7082
) -> BaseType: # noqa: C901
7183
"""
7284
Convert a Pydantic type to a Graphene Field type, including not just the
7385
native Python type but any additional metadata (e.g. shape) that Pydantic
7486
knows about.
7587
"""
76-
graphene_type = find_graphene_type(type_, field, registry)
88+
graphene_type = find_graphene_type(
89+
type_, field, registry, parent_type=parent_type, model=model
90+
)
7791
if field.shape == fields.Shape.SINGLETON:
7892
return graphene_type
7993
elif field.shape in (
@@ -90,7 +104,11 @@ def convert_pydantic_type(
90104

91105

92106
def find_graphene_type(
93-
type_: T.Type, field: fields.Field, registry: Registry = None
107+
type_: T.Type,
108+
field: fields.Field,
109+
registry: Registry = None,
110+
parent_type: T.Type = None,
111+
model: T.Type[BaseModel] = None,
94112
) -> BaseType: # noqa: C901
95113
"""
96114
Map a native Python type to a Graphene-supported Field type, where possible,
@@ -114,25 +132,55 @@ def find_graphene_type(
114132
return GrapheneDecimal if DECIMAL_SUPPORTED else Float
115133
elif type_ == int:
116134
return Int
117-
# NOTE: this has to come before any `issubclass()` checks, because annotated
118-
# generic types aren't valid arguments to `issubclass`
119-
elif hasattr(type_, "__origin__"):
120-
return convert_generic_python_type(type_, field, registry)
121135
elif type_ in (tuple, list, set):
122136
# TODO: do Sets really belong here?
123137
return List
124-
elif issubclass(type_, enum.Enum):
125-
return Enum.from_enum(type_)
126138
elif registry and registry.get_type_for_model(type_):
127139
return registry.get_type_for_model(type_)
140+
elif registry and isinstance(type_, BaseModel):
141+
# If it's a Pydantic model that hasn't yet been wrapped with a ObjectType,
142+
# we can put a placeholder in and request that `resolve_placeholders()`
143+
# be called to update it.
144+
registry.add_placeholder_for_model(type_)
145+
# NOTE: this has to come before any `issubclass()` checks, because annotated
146+
# generic types aren't valid arguments to `issubclass`
147+
elif hasattr(type_, "__origin__"):
148+
return convert_generic_python_type(
149+
type_, field, registry, parent_type=parent_type, model=model
150+
)
151+
elif isinstance(type_, T.ForwardRef):
152+
# A special case! We have to do a little hackery to try and resolve
153+
# the type that this points to, by trying to reference a "sibling" type
154+
# to where this was defined so we can get access to that namespace...
155+
sibling = model or parent_type
156+
if not sibling:
157+
raise ConversionError(
158+
"Don't know how to convert the Pydantic field "
159+
f"{field!r} ({field.type_}), could not resolve "
160+
"the forward reference."
161+
)
162+
module_ns = sys.modules[sibling.__module__].__dict__
163+
resolved = type_._evaluate(module_ns, None)
164+
# TODO: make this behavior optional. maybe this is a place for the TypeOptions to play a role?
165+
if registry:
166+
registry.add_placeholder_for_model(resolved)
167+
return find_graphene_type(
168+
resolved, field, registry, parent_type=parent_type, model=model
169+
)
170+
elif issubclass(type_, enum.Enum):
171+
return Enum.from_enum(type_)
128172
else:
129173
raise ConversionError(
130174
f"Don't know how to convert the Pydantic field {field!r} ({field.type_})"
131175
)
132176

133177

134178
def convert_generic_python_type(
135-
type_: T.Type, field: fields.Field, registry: Registry = None
179+
type_: T.Type,
180+
field: fields.Field,
181+
registry: Registry = None,
182+
parent_type: T.Type = None,
183+
model: T.Type[BaseModel] = None,
136184
) -> BaseType: # noqa: C901
137185
"""
138186
Convert annotated Python generic types into the most appropriate Graphene
@@ -146,7 +194,9 @@ def convert_generic_python_type(
146194
# decide whether the origin type is a subtype of, say, T.Iterable since typical
147195
# Python functions like `isinstance()` don't work
148196
if origin == T.Union:
149-
return convert_union_type(type_, field, registry)
197+
return convert_union_type(
198+
type_, field, registry, parent_type=parent_type, model=model
199+
)
150200
elif origin in (
151201
T.Tuple,
152202
T.List,
@@ -155,7 +205,7 @@ def convert_generic_python_type(
155205
T.Iterable,
156206
list,
157207
set,
158-
) or issubclass(origin, abc.Sequence):
208+
) or issubclass(origin, collections.abc.Sequence):
159209
# TODO: find a better way of divining that the origin is sequence-like
160210
inner_types = getattr(type_, "__args__", [])
161211
if not inner_types: # pragma: no cover # this really should be impossible
@@ -165,16 +215,26 @@ def convert_generic_python_type(
165215
# Of course, we can only return a homogeneous type here, so we pick the
166216
# first of the wrapped types
167217
inner_type = inner_types[0]
168-
return List(find_graphene_type(inner_type, field, registry))
218+
return List(
219+
find_graphene_type(
220+
inner_type, field, registry, parent_type=parent_type, model=model
221+
)
222+
)
169223
elif origin in (T.Dict, T.Mapping, collections.OrderedDict, dict) or issubclass(
170-
origin, abc.Mapping
224+
origin, collections.abc.Mapping
171225
):
172226
raise ConversionError("Don't know how to handle mappings in Graphene")
173227
else:
174228
raise ConversionError(f"Don't know how to handle {type_} (generic: {origin})")
175229

176230

177-
def convert_union_type(type_: T.Type, field: fields.Field, registry: Registry = None):
231+
def convert_union_type(
232+
type_: T.Type,
233+
field: fields.Field,
234+
registry: Registry = None,
235+
parent_type: T.Type = None,
236+
model: T.Type[BaseModel] = None,
237+
):
178238
"""
179239
Convert an annotated Python Union type into a Graphene Union.
180240
"""
@@ -184,12 +244,17 @@ def convert_union_type(type_: T.Type, field: fields.Field, registry: Registry =
184244
# typing.Union[None, T] -- we can return the Graphene type for T directly
185245
# since Pydantic will have already parsed it as optional
186246
native_type = next(x for x in inner_types if x != NONE_TYPE) # noqa: E721
187-
graphene_type = find_graphene_type(native_type, field, registry)
247+
graphene_type = find_graphene_type(
248+
native_type, field, registry, parent_type=parent_type, model=model
249+
)
188250
return graphene_type
189251

190252
# Otherwise, we use a little metaprogramming -- create our own unique
191253
# subclass of graphene.Union that knows its constituent Graphene types
192-
parent_types = tuple(find_graphene_type(x, field, registry) for x in inner_types)
254+
parent_types = tuple(
255+
find_graphene_type(x, field, registry, parent_type=parent_type, model=model)
256+
for x in inner_types
257+
)
193258
internal_meta_cls = type("Meta", (), {"types": parent_types})
194259

195260
union_cls = type(

graphene_pydantic/registry.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ def assert_is_pydantic_object_type(obj_type: T.Type["PydanticObjectType"]):
1616
raise TypeError(f"Expected PydanticObjectType, but got: {obj_type!r}")
1717

1818

19+
class Placeholder:
20+
def __init__(self, model: T.Type[BaseModel]):
21+
self.model = model
22+
23+
def __repr__(self):
24+
return f"{self.__class__.__name__}({self.model})"
25+
26+
1927
class Registry:
2028
"""Hold information about Pydantic models and how they (and their fields) map to Graphene types."""
2129

@@ -35,8 +43,17 @@ def register(self, obj_type: T.Type["PydanticObjectType"]):
3543
def get_type_for_model(self, model: T.Type[BaseModel]) -> "PydanticObjectType":
3644
return self._registry.get(model)
3745

46+
def add_placeholder_for_model(self, model: T.Type[BaseModel]):
47+
if model in self._registry:
48+
return
49+
self._registry[model] = Placeholder(model)
50+
3851
def register_object_field(
39-
self, obj_type: T.Type["PydanticObjectType"], field_name: str, obj_field: Field
52+
self,
53+
obj_type: T.Type["PydanticObjectType"],
54+
field_name: str,
55+
obj_field: Field,
56+
model: T.Type[BaseModel] = None,
4057
):
4158
assert_is_pydantic_object_type(obj_type)
4259

graphene_pydantic/types.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from graphene.types.objecttype import ObjectTypeOptions
77
from graphene.types.utils import yank_fields_from_attrs
88

9-
from .registry import get_global_registry, Registry
9+
from .registry import get_global_registry, Registry, Placeholder
1010
from .converters import convert_pydantic_field
1111

1212

@@ -44,8 +44,10 @@ def construct_fields(
4444

4545
fields = {}
4646
for name, field in fields_to_convert:
47-
converted = convert_pydantic_field(field, registry)
48-
registry.register_object_field(obj_type, name, field)
47+
converted = convert_pydantic_field(
48+
field, registry, parent_type=obj_type, model=model
49+
)
50+
registry.register_object_field(obj_type, name, field, model=model)
4951
fields[name] = converted
5052
return fields
5153

@@ -123,3 +125,32 @@ def __init_subclass_with_meta__(
123125

124126
if not skip_registry:
125127
registry.register(cls)
128+
129+
@classmethod
130+
def resolve_placeholders(cls):
131+
"""
132+
If this class has any placeholders in the registry (e.g. classes that
133+
weren't resolvable when the class was created, perhaps due to the
134+
PydanticObjectType wrapper not existing yet), resolve them as far as
135+
possible.
136+
"""
137+
meta = cls._meta
138+
fields_to_update = {}
139+
for name, field in meta.fields.items():
140+
target_type = field._type
141+
if hasattr(target_type, "_of_type"):
142+
target_type = target_type._of_type
143+
if isinstance(target_type, Placeholder):
144+
pydantic_field = meta.model.__fields__[name]
145+
graphene_field = convert_pydantic_field(
146+
pydantic_field,
147+
meta.registry,
148+
parent_type=cls,
149+
model=target_type.model,
150+
)
151+
fields_to_update[name] = graphene_field
152+
meta.registry.register_object_field(
153+
cls, name, pydantic_field, model=target_type.model
154+
)
155+
# update the graphene side of things
156+
meta.fields.update(fields_to_update)

0 commit comments

Comments
 (0)