Skip to content

Commit ad9c1aa

Browse files
authored
Merge branch 'master' into add-filters
2 parents a2b8a9b + b94230e commit ad9c1aa

File tree

8 files changed

+174
-6
lines changed

8 files changed

+174
-6
lines changed

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,21 @@ class Query(graphene.ObjectType):
6363
schema = graphene.Schema(query=Query)
6464
```
6565

66+
We need a database session first:
67+
68+
```python
69+
from sqlalchemy import (create_engine)
70+
from sqlalchemy.orm import (scoped_session, sessionmaker)
71+
72+
engine = create_engine('sqlite:///database.sqlite3', convert_unicode=True)
73+
db_session = scoped_session(sessionmaker(autocommit=False,
74+
autoflush=False,
75+
bind=engine))
76+
# We will need this for querying, Graphene extracts the session from the base.
77+
# Alternatively it can be provided in the GraphQLResolveInfo.context dictionary under context["session"]
78+
Base.query = db_session.query_property()
79+
```
80+
6681
Then you can simply query the schema:
6782

6883
```python

graphene_sqlalchemy/batching.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,7 @@ def _get_loader(relationship_prop):
137137
RELATIONSHIP_LOADERS_CACHE[relationship_prop] = loader
138138
return loader
139139

140-
loader = _get_loader(relationship_prop)
141-
142140
async def resolve(root, info, **args):
143-
return await loader.load(root)
141+
return await _get_loader(relationship_prop).load(root)
144142

145143
return resolve

graphene_sqlalchemy/converter.py

Lines changed: 50 additions & 1 deletion
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
@@ -100,6 +106,49 @@ def is_column_nullable(column):
100106
return bool(getattr(column, "nullable", True))
101107

102108

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

graphene_sqlalchemy/tests/models.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
Table,
1919
func,
2020
)
21+
from sqlalchemy.ext.associationproxy import association_proxy
2122
from sqlalchemy.ext.declarative import declarative_base
2223
from sqlalchemy.ext.hybrid import hybrid_property
2324
from sqlalchemy.orm import backref, column_property, composite, mapper, relationship
@@ -29,6 +30,7 @@
2930
SQL_VERSION_HIGHER_EQUAL_THAN_2,
3031
)
3132

33+
# fmt: off
3234
if SQL_VERSION_HIGHER_EQUAL_THAN_2:
3335
from sqlalchemy.sql.sqltypes import HasExpressionLookup # noqa # isort:skip
3436
else:
@@ -81,6 +83,18 @@ def __repr__(self):
8183
return "{} {}".format(self.first_name, self.last_name)
8284

8385

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

@@ -138,6 +152,8 @@ def hybrid_prop_list(self) -> List[int]:
138152
CompositeFullName, first_name, last_name, doc="Composite"
139153
)
140154

155+
headlines = association_proxy("articles", "headline")
156+
141157

142158
articles_tags_table = Table(
143159
"articles_tags",
@@ -169,6 +185,7 @@ class Article(Base):
169185
readers = relationship(
170186
"Reader", secondary="articles_readers", back_populates="articles"
171187
)
188+
recommended_reads = association_proxy("reporter", "articles")
172189

173190
# one-to-one relationship with image
174191
image_id = Column(Integer(), ForeignKey("images.id"), unique=True)

graphene_sqlalchemy/tests/test_converter.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from graphene.types.structures import Structure
1818

1919
from ..converter import (
20+
convert_sqlalchemy_association_proxy,
2021
convert_sqlalchemy_column,
2122
convert_sqlalchemy_composite,
2223
convert_sqlalchemy_hybrid_method,
@@ -33,6 +34,7 @@
3334
CompositeFullName,
3435
CustomColumnModel,
3536
Pet,
37+
ProxiedReporter,
3638
Reporter,
3739
ShoppingCart,
3840
ShoppingCartItem,
@@ -656,6 +658,64 @@ class Meta:
656658
assert graphene_type.type == A
657659

658660

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

graphene_sqlalchemy/tests/test_query.py

Lines changed: 2 additions & 0 deletions
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
}

graphene_sqlalchemy/tests/test_types.py

Lines changed: 15 additions & 1 deletion
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

graphene_sqlalchemy/types.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing import Any, Optional, Type, Union
88

99
import sqlalchemy
10+
from sqlalchemy.ext.associationproxy import AssociationProxy
1011
from sqlalchemy.ext.hybrid import hybrid_property
1112
from sqlalchemy.orm import ColumnProperty, CompositeProperty, RelationshipProperty
1213
from sqlalchemy.orm.exc import NoResultFound
@@ -22,6 +23,7 @@
2223
from graphene.utils.orderedtype import OrderedType
2324

2425
from .converter import (
26+
convert_sqlalchemy_association_proxy,
2527
convert_sqlalchemy_column,
2628
convert_sqlalchemy_composite,
2729
convert_sqlalchemy_hybrid_method,
@@ -303,7 +305,7 @@ def construct_fields_and_filters(
303305
+ [
304306
(name, item)
305307
for name, item in inspected_model.all_orm_descriptors.items()
306-
if isinstance(item, hybrid_property)
308+
if isinstance(item, hybrid_property) or isinstance(item, AssociationProxy)
307309
]
308310
+ inspected_model.relationships.items()
309311
)
@@ -386,6 +388,17 @@ def construct_fields_and_filters(
386388
field = convert_sqlalchemy_composite(attr, registry, resolver)
387389
elif isinstance(attr, hybrid_property):
388390
field = convert_sqlalchemy_hybrid_method(attr, resolver, **orm_field.kwargs)
391+
elif isinstance(attr, AssociationProxy):
392+
field = convert_sqlalchemy_association_proxy(
393+
model,
394+
attr,
395+
obj_type,
396+
registry,
397+
connection_field_factory,
398+
batching,
399+
resolver,
400+
**orm_field.kwargs
401+
)
389402
else:
390403
raise Exception("Property type is not supported") # Should never happen
391404

0 commit comments

Comments
 (0)