Skip to content

Commit 163ea00

Browse files
authored
Merge pull request #8 from upsidetravel/feature/forward-references
Support forward & circular references
2 parents 424670f + df8c53e commit 163ea00

File tree

5 files changed

+274
-27
lines changed

5 files changed

+274
-27
lines changed

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,32 @@ 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 NodeModel(BaseModel):
70+
id: int
71+
name: str
72+
labels: 'LabelsModel'
73+
74+
class LabelsModel(BaseModel):
75+
node: NodeModel
76+
labels: typing.List[str]
77+
78+
class Node(PydanticObjectType):
79+
class Meta:
80+
model = NodeModel
81+
82+
class Labels(PydanticObjectType):
83+
class Meta:
84+
model = LabelsModel
85+
86+
87+
Node.resolve_placeholders() # make the `labels` field work
88+
Labels.resolve_placeholders() # make the `node` field work
89+
```
6490

6591
### Full Examples
6692

graphene_pydantic/converters.py

Lines changed: 89 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,56 @@ 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. Did you call `resolve_placeholders()`? "
161+
"See the README for more on forward references."
162+
)
163+
module_ns = sys.modules[sibling.__module__].__dict__
164+
resolved = type_._evaluate(module_ns, None)
165+
# TODO: make this behavior optional. maybe this is a place for the TypeOptions to play a role?
166+
if registry:
167+
registry.add_placeholder_for_model(resolved)
168+
return find_graphene_type(
169+
resolved, field, registry, parent_type=parent_type, model=model
170+
)
171+
elif issubclass(type_, enum.Enum):
172+
return Enum.from_enum(type_)
128173
else:
129174
raise ConversionError(
130175
f"Don't know how to convert the Pydantic field {field!r} ({field.type_})"
131176
)
132177

133178

134179
def convert_generic_python_type(
135-
type_: T.Type, field: fields.Field, registry: Registry = None
180+
type_: T.Type,
181+
field: fields.Field,
182+
registry: Registry = None,
183+
parent_type: T.Type = None,
184+
model: T.Type[BaseModel] = None,
136185
) -> BaseType: # noqa: C901
137186
"""
138187
Convert annotated Python generic types into the most appropriate Graphene
@@ -146,7 +195,9 @@ def convert_generic_python_type(
146195
# decide whether the origin type is a subtype of, say, T.Iterable since typical
147196
# Python functions like `isinstance()` don't work
148197
if origin == T.Union:
149-
return convert_union_type(type_, field, registry)
198+
return convert_union_type(
199+
type_, field, registry, parent_type=parent_type, model=model
200+
)
150201
elif origin in (
151202
T.Tuple,
152203
T.List,
@@ -155,7 +206,7 @@ def convert_generic_python_type(
155206
T.Iterable,
156207
list,
157208
set,
158-
) or issubclass(origin, abc.Sequence):
209+
) or issubclass(origin, collections.abc.Sequence):
159210
# TODO: find a better way of divining that the origin is sequence-like
160211
inner_types = getattr(type_, "__args__", [])
161212
if not inner_types: # pragma: no cover # this really should be impossible
@@ -165,16 +216,26 @@ def convert_generic_python_type(
165216
# Of course, we can only return a homogeneous type here, so we pick the
166217
# first of the wrapped types
167218
inner_type = inner_types[0]
168-
return List(find_graphene_type(inner_type, field, registry))
219+
return List(
220+
find_graphene_type(
221+
inner_type, field, registry, parent_type=parent_type, model=model
222+
)
223+
)
169224
elif origin in (T.Dict, T.Mapping, collections.OrderedDict, dict) or issubclass(
170-
origin, abc.Mapping
225+
origin, collections.abc.Mapping
171226
):
172227
raise ConversionError("Don't know how to handle mappings in Graphene")
173228
else:
174229
raise ConversionError(f"Don't know how to handle {type_} (generic: {origin})")
175230

176231

177-
def convert_union_type(type_: T.Type, field: fields.Field, registry: Registry = None):
232+
def convert_union_type(
233+
type_: T.Type,
234+
field: fields.Field,
235+
registry: Registry = None,
236+
parent_type: T.Type = None,
237+
model: T.Type[BaseModel] = None,
238+
):
178239
"""
179240
Convert an annotated Python Union type into a Graphene Union.
180241
"""
@@ -184,12 +245,17 @@ def convert_union_type(type_: T.Type, field: fields.Field, registry: Registry =
184245
# typing.Union[None, T] -- we can return the Graphene type for T directly
185246
# since Pydantic will have already parsed it as optional
186247
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)
248+
graphene_type = find_graphene_type(
249+
native_type, field, registry, parent_type=parent_type, model=model
250+
)
188251
return graphene_type
189252

190253
# Otherwise, we use a little metaprogramming -- create our own unique
191254
# 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)
255+
parent_types = tuple(
256+
find_graphene_type(x, field, registry, parent_type=parent_type, model=model)
257+
for x in inner_types
258+
)
193259
internal_meta_cls = type("Meta", (), {"types": parent_types})
194260

195261
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)