Skip to content

Commit 1436807

Browse files
authored
feat: association_proxy support (#267)
* association_proxy support * better support for assoc proxy lists (rather than one-to-one) * scope down * add support for sqlalchemy 1.1 * fix pytest due to master merge * fix: throw error when association proxy could not be converted * fix: adjust association proxy to new relationship handling --------- Co-authored-by: Erik Wrede <[email protected]>
1 parent f5f05d1 commit 1436807

File tree

6 files changed

+157
-3
lines changed

6 files changed

+157
-3
lines changed

Diff for: graphene_sqlalchemy/converter.py

+50-1
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,14 @@
77

88
from sqlalchemy import types as sqa_types
99
from sqlalchemy.dialects import postgresql
10+
from sqlalchemy.orm import (
11+
ColumnProperty,
12+
RelationshipProperty,
13+
class_mapper,
14+
interfaces,
15+
strategies,
16+
)
1017
from sqlalchemy.ext.hybrid import hybrid_property
11-
from sqlalchemy.orm import interfaces, strategies
1218

1319
import graphene
1420
from graphene.types.json import JSONString
@@ -101,6 +107,49 @@ def is_column_nullable(column):
101107
return bool(getattr(column, "nullable", True))
102108

103109

110+
def convert_sqlalchemy_association_proxy(
111+
parent,
112+
assoc_prop,
113+
obj_type,
114+
registry,
115+
connection_field_factory,
116+
batching,
117+
resolver,
118+
**field_kwargs,
119+
):
120+
def dynamic_type():
121+
prop = class_mapper(parent).attrs[assoc_prop.target_collection]
122+
scalar = not prop.uselist
123+
model = prop.mapper.class_
124+
attr = class_mapper(model).attrs[assoc_prop.value_attr]
125+
126+
if isinstance(attr, ColumnProperty):
127+
field = convert_sqlalchemy_column(attr, registry, resolver, **field_kwargs)
128+
if not scalar:
129+
# repackage as List
130+
field.__dict__["_type"] = graphene.List(field.type)
131+
return field
132+
elif isinstance(attr, RelationshipProperty):
133+
return convert_sqlalchemy_relationship(
134+
attr,
135+
obj_type,
136+
connection_field_factory,
137+
field_kwargs.pop("batching", batching),
138+
assoc_prop.value_attr,
139+
**field_kwargs,
140+
).get_type()
141+
else:
142+
raise TypeError(
143+
"Unsupported association proxy target type: {} for prop {} on type {}. "
144+
"Please disable the conversion of this field using an ORMField.".format(
145+
type(attr), assoc_prop, obj_type
146+
)
147+
)
148+
# else, not supported
149+
150+
return graphene.Dynamic(dynamic_type)
151+
152+
104153
def convert_sqlalchemy_relationship(
105154
relationship_prop,
106155
obj_type,

Diff for: graphene_sqlalchemy/tests/models.py

+16
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
Table,
1818
func,
1919
)
20+
from sqlalchemy.ext.associationproxy import association_proxy
2021
from sqlalchemy.ext.declarative import declarative_base
2122
from sqlalchemy.ext.hybrid import hybrid_property
2223
from sqlalchemy.orm import backref, column_property, composite, mapper, relationship
@@ -78,6 +79,18 @@ def __repr__(self):
7879
return "{} {}".format(self.first_name, self.last_name)
7980

8081

82+
class ProxiedReporter(Base):
83+
__tablename__ = "reporters_error"
84+
id = Column(Integer(), primary_key=True)
85+
first_name = Column(String(30), doc="First name")
86+
last_name = Column(String(30), doc="Last name")
87+
reporter_id = Column(Integer(), ForeignKey("reporters.id"))
88+
reporter = relationship("Reporter", uselist=False)
89+
90+
# This is a hybrid property, we don't support proxies on hybrids yet
91+
composite_prop = association_proxy("reporter", "composite_prop")
92+
93+
8194
class Reporter(Base):
8295
__tablename__ = "reporters"
8396

@@ -135,6 +148,8 @@ def hybrid_prop_list(self) -> List[int]:
135148
CompositeFullName, first_name, last_name, doc="Composite"
136149
)
137150

151+
headlines = association_proxy("articles", "headline")
152+
138153

139154
class Article(Base):
140155
__tablename__ = "articles"
@@ -145,6 +160,7 @@ class Article(Base):
145160
readers = relationship(
146161
"Reader", secondary="articles_readers", back_populates="articles"
147162
)
163+
recommended_reads = association_proxy("reporter", "articles")
148164

149165

150166
class Reader(Base):

Diff for: graphene_sqlalchemy/tests/test_converter.py

+60
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
)
2626
from .utils import wrap_select_func
2727
from ..converter import (
28+
convert_sqlalchemy_association_proxy,
2829
convert_sqlalchemy_column,
2930
convert_sqlalchemy_composite,
3031
convert_sqlalchemy_hybrid_method,
@@ -41,6 +42,7 @@
4142
CompositeFullName,
4243
CustomColumnModel,
4344
Pet,
45+
ProxiedReporter,
4446
Reporter,
4547
ShoppingCart,
4648
ShoppingCartItem,
@@ -650,6 +652,64 @@ class Meta:
650652
assert graphene_type.type == A
651653

652654

655+
def test_should_convert_association_proxy():
656+
class ReporterType(SQLAlchemyObjectType):
657+
class Meta:
658+
model = Reporter
659+
660+
class ArticleType(SQLAlchemyObjectType):
661+
class Meta:
662+
model = Article
663+
664+
field = convert_sqlalchemy_association_proxy(
665+
Reporter,
666+
Reporter.headlines,
667+
ReporterType,
668+
get_global_registry(),
669+
default_connection_field_factory,
670+
True,
671+
mock_resolver,
672+
)
673+
assert isinstance(field, graphene.Dynamic)
674+
assert isinstance(field.get_type().type, graphene.List)
675+
assert field.get_type().type.of_type == graphene.String
676+
677+
dynamic_field = convert_sqlalchemy_association_proxy(
678+
Article,
679+
Article.recommended_reads,
680+
ArticleType,
681+
get_global_registry(),
682+
default_connection_field_factory,
683+
True,
684+
mock_resolver,
685+
)
686+
dynamic_field_type = dynamic_field.get_type().type
687+
assert isinstance(dynamic_field, graphene.Dynamic)
688+
assert isinstance(dynamic_field_type, graphene.NonNull)
689+
assert isinstance(dynamic_field_type.of_type, graphene.List)
690+
assert isinstance(dynamic_field_type.of_type.of_type, graphene.NonNull)
691+
assert dynamic_field_type.of_type.of_type.of_type == ArticleType
692+
693+
694+
def test_should_throw_error_association_proxy_unsupported_target():
695+
class ProxiedReporterType(SQLAlchemyObjectType):
696+
class Meta:
697+
model = ProxiedReporter
698+
699+
field = convert_sqlalchemy_association_proxy(
700+
ProxiedReporter,
701+
ProxiedReporter.composite_prop,
702+
ProxiedReporterType,
703+
get_global_registry(),
704+
default_connection_field_factory,
705+
True,
706+
mock_resolver,
707+
)
708+
709+
with pytest.raises(TypeError):
710+
field.get_type()
711+
712+
653713
def test_should_postgresql_uuid_convert():
654714
assert get_field(postgresql.UUID()).type == graphene.UUID
655715

Diff for: graphene_sqlalchemy/tests/test_query.py

+2
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ async def resolve_reporters(self, _info):
8080
columnProp
8181
hybridProp
8282
compositeProp
83+
headlines
8384
}
8485
reporters {
8586
firstName
@@ -92,6 +93,7 @@ async def resolve_reporters(self, _info):
9293
"hybridProp": "John",
9394
"columnProp": 2,
9495
"compositeProp": "John Doe",
96+
"headlines": ["Hi!"],
9597
},
9698
"reporters": [{"firstName": "John"}, {"firstName": "Jane"}],
9799
}

Diff for: graphene_sqlalchemy/tests/test_types.py

+15-1
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ class Meta:
138138
"pets",
139139
"articles",
140140
"favorite_article",
141+
# AssociationProxy
142+
"headlines",
141143
]
142144
)
143145

@@ -206,6 +208,16 @@ class Meta:
206208
assert favorite_article_field.type().type == ArticleType
207209
assert favorite_article_field.type().description is None
208210

211+
# assocation proxy
212+
assoc_field = ReporterType._meta.fields["headlines"]
213+
assert isinstance(assoc_field, Dynamic)
214+
assert isinstance(assoc_field.type().type, List)
215+
assert assoc_field.type().type.of_type == String
216+
217+
assoc_field = ArticleType._meta.fields["recommended_reads"]
218+
assert isinstance(assoc_field, Dynamic)
219+
assert assoc_field.type().type == ArticleType.connection
220+
209221

210222
def test_sqlalchemy_override_fields():
211223
@convert_sqlalchemy_composite.register(CompositeFullName)
@@ -275,6 +287,7 @@ class Meta:
275287
"hybrid_prop_float",
276288
"hybrid_prop_bool",
277289
"hybrid_prop_list",
290+
"headlines",
278291
]
279292
)
280293

@@ -390,6 +403,7 @@ class Meta:
390403
"pets",
391404
"articles",
392405
"favorite_article",
406+
"headlines",
393407
]
394408
)
395409

@@ -510,7 +524,7 @@ class Meta:
510524

511525
assert issubclass(CustomReporterType, ObjectType)
512526
assert CustomReporterType._meta.model == Reporter
513-
assert len(CustomReporterType._meta.fields) == 17
527+
assert len(CustomReporterType._meta.fields) == 18
514528

515529

516530
# Test Custom SQLAlchemyObjectType with Custom Options

Diff for: graphene_sqlalchemy/types.py

+14-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import Any
44

55
import sqlalchemy
6+
from sqlalchemy.ext.associationproxy import AssociationProxy
67
from sqlalchemy.ext.hybrid import hybrid_property
78
from sqlalchemy.orm import ColumnProperty, CompositeProperty, RelationshipProperty
89
from sqlalchemy.orm.exc import NoResultFound
@@ -16,6 +17,7 @@
1617
from graphene.utils.orderedtype import OrderedType
1718

1819
from .converter import (
20+
convert_sqlalchemy_association_proxy,
1921
convert_sqlalchemy_column,
2022
convert_sqlalchemy_composite,
2123
convert_sqlalchemy_hybrid_method,
@@ -152,7 +154,7 @@ def construct_fields(
152154
+ [
153155
(name, item)
154156
for name, item in inspected_model.all_orm_descriptors.items()
155-
if isinstance(item, hybrid_property)
157+
if isinstance(item, hybrid_property) or isinstance(item, AssociationProxy)
156158
]
157159
+ inspected_model.relationships.items()
158160
)
@@ -230,6 +232,17 @@ def construct_fields(
230232
field = convert_sqlalchemy_composite(attr, registry, resolver)
231233
elif isinstance(attr, hybrid_property):
232234
field = convert_sqlalchemy_hybrid_method(attr, resolver, **orm_field.kwargs)
235+
elif isinstance(attr, AssociationProxy):
236+
field = convert_sqlalchemy_association_proxy(
237+
model,
238+
attr,
239+
obj_type,
240+
registry,
241+
connection_field_factory,
242+
batching,
243+
resolver,
244+
**orm_field.kwargs
245+
)
233246
else:
234247
raise Exception("Property type is not supported") # Should never happen
235248

0 commit comments

Comments
 (0)