Skip to content

Commit

Permalink
Merge branch 'master' into add-filters
Browse files Browse the repository at this point in the history
  • Loading branch information
erikwrede authored Nov 20, 2023
2 parents a2b8a9b + b94230e commit ad9c1aa
Show file tree
Hide file tree
Showing 8 changed files with 174 additions and 6 deletions.
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,21 @@ class Query(graphene.ObjectType):
schema = graphene.Schema(query=Query)
```

We need a database session first:

```python
from sqlalchemy import (create_engine)
from sqlalchemy.orm import (scoped_session, sessionmaker)

engine = create_engine('sqlite:///database.sqlite3', convert_unicode=True)
db_session = scoped_session(sessionmaker(autocommit=False,
autoflush=False,
bind=engine))
# We will need this for querying, Graphene extracts the session from the base.
# Alternatively it can be provided in the GraphQLResolveInfo.context dictionary under context["session"]
Base.query = db_session.query_property()
```

Then you can simply query the schema:

```python
Expand Down
4 changes: 1 addition & 3 deletions graphene_sqlalchemy/batching.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,7 @@ def _get_loader(relationship_prop):
RELATIONSHIP_LOADERS_CACHE[relationship_prop] = loader
return loader

loader = _get_loader(relationship_prop)

async def resolve(root, info, **args):
return await loader.load(root)
return await _get_loader(relationship_prop).load(root)

return resolve
51 changes: 50 additions & 1 deletion graphene_sqlalchemy/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,14 @@

from sqlalchemy import types as sqa_types
from sqlalchemy.dialects import postgresql
from sqlalchemy.orm import (
ColumnProperty,
RelationshipProperty,
class_mapper,
interfaces,
strategies,
)
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import interfaces, strategies

import graphene
from graphene.types.json import JSONString
Expand Down Expand Up @@ -100,6 +106,49 @@ def is_column_nullable(column):
return bool(getattr(column, "nullable", True))


def convert_sqlalchemy_association_proxy(
parent,
assoc_prop,
obj_type,
registry,
connection_field_factory,
batching,
resolver,
**field_kwargs,
):
def dynamic_type():
prop = class_mapper(parent).attrs[assoc_prop.target_collection]
scalar = not prop.uselist
model = prop.mapper.class_
attr = class_mapper(model).attrs[assoc_prop.value_attr]

if isinstance(attr, ColumnProperty):
field = convert_sqlalchemy_column(attr, registry, resolver, **field_kwargs)
if not scalar:
# repackage as List
field.__dict__["_type"] = graphene.List(field.type)
return field
elif isinstance(attr, RelationshipProperty):
return convert_sqlalchemy_relationship(
attr,
obj_type,
connection_field_factory,
field_kwargs.pop("batching", batching),
assoc_prop.value_attr,
**field_kwargs,
).get_type()
else:
raise TypeError(
"Unsupported association proxy target type: {} for prop {} on type {}. "
"Please disable the conversion of this field using an ORMField.".format(
type(attr), assoc_prop, obj_type
)
)
# else, not supported

return graphene.Dynamic(dynamic_type)


def convert_sqlalchemy_relationship(
relationship_prop,
obj_type,
Expand Down
17 changes: 17 additions & 0 deletions graphene_sqlalchemy/tests/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
Table,
func,
)
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import backref, column_property, composite, mapper, relationship
Expand All @@ -29,6 +30,7 @@
SQL_VERSION_HIGHER_EQUAL_THAN_2,
)

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


class ProxiedReporter(Base):
__tablename__ = "reporters_error"
id = Column(Integer(), primary_key=True)
first_name = Column(String(30), doc="First name")
last_name = Column(String(30), doc="Last name")
reporter_id = Column(Integer(), ForeignKey("reporters.id"))
reporter = relationship("Reporter", uselist=False)

# This is a hybrid property, we don't support proxies on hybrids yet
composite_prop = association_proxy("reporter", "composite_prop")


class Reporter(Base):
__tablename__ = "reporters"

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

headlines = association_proxy("articles", "headline")


articles_tags_table = Table(
"articles_tags",
Expand Down Expand Up @@ -169,6 +185,7 @@ class Article(Base):
readers = relationship(
"Reader", secondary="articles_readers", back_populates="articles"
)
recommended_reads = association_proxy("reporter", "articles")

# one-to-one relationship with image
image_id = Column(Integer(), ForeignKey("images.id"), unique=True)
Expand Down
60 changes: 60 additions & 0 deletions graphene_sqlalchemy/tests/test_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from graphene.types.structures import Structure

from ..converter import (
convert_sqlalchemy_association_proxy,
convert_sqlalchemy_column,
convert_sqlalchemy_composite,
convert_sqlalchemy_hybrid_method,
Expand All @@ -33,6 +34,7 @@
CompositeFullName,
CustomColumnModel,
Pet,
ProxiedReporter,
Reporter,
ShoppingCart,
ShoppingCartItem,
Expand Down Expand Up @@ -656,6 +658,64 @@ class Meta:
assert graphene_type.type == A


def test_should_convert_association_proxy():
class ReporterType(SQLAlchemyObjectType):
class Meta:
model = Reporter

class ArticleType(SQLAlchemyObjectType):
class Meta:
model = Article

field = convert_sqlalchemy_association_proxy(
Reporter,
Reporter.headlines,
ReporterType,
get_global_registry(),
default_connection_field_factory,
True,
mock_resolver,
)
assert isinstance(field, graphene.Dynamic)
assert isinstance(field.get_type().type, graphene.List)
assert field.get_type().type.of_type == graphene.String

dynamic_field = convert_sqlalchemy_association_proxy(
Article,
Article.recommended_reads,
ArticleType,
get_global_registry(),
default_connection_field_factory,
True,
mock_resolver,
)
dynamic_field_type = dynamic_field.get_type().type
assert isinstance(dynamic_field, graphene.Dynamic)
assert isinstance(dynamic_field_type, graphene.NonNull)
assert isinstance(dynamic_field_type.of_type, graphene.List)
assert isinstance(dynamic_field_type.of_type.of_type, graphene.NonNull)
assert dynamic_field_type.of_type.of_type.of_type == ArticleType


def test_should_throw_error_association_proxy_unsupported_target():
class ProxiedReporterType(SQLAlchemyObjectType):
class Meta:
model = ProxiedReporter

field = convert_sqlalchemy_association_proxy(
ProxiedReporter,
ProxiedReporter.composite_prop,
ProxiedReporterType,
get_global_registry(),
default_connection_field_factory,
True,
mock_resolver,
)

with pytest.raises(TypeError):
field.get_type()


def test_should_postgresql_uuid_convert():
assert get_field(postgresql.UUID()).type == graphene.UUID

Expand Down
2 changes: 2 additions & 0 deletions graphene_sqlalchemy/tests/test_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ async def resolve_reporters(self, _info):
columnProp
hybridProp
compositeProp
headlines
}
reporters {
firstName
Expand All @@ -92,6 +93,7 @@ async def resolve_reporters(self, _info):
"hybridProp": "John",
"columnProp": 2,
"compositeProp": "John Doe",
"headlines": ["Hi!"],
},
"reporters": [{"firstName": "John"}, {"firstName": "Jane"}],
}
Expand Down
16 changes: 15 additions & 1 deletion graphene_sqlalchemy/tests/test_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ class Meta:
"pets",
"articles",
"favorite_article",
# AssociationProxy
"headlines",
]
)

Expand Down Expand Up @@ -206,6 +208,16 @@ class Meta:
assert favorite_article_field.type().type == ArticleType
assert favorite_article_field.type().description is None

# assocation proxy
assoc_field = ReporterType._meta.fields["headlines"]
assert isinstance(assoc_field, Dynamic)
assert isinstance(assoc_field.type().type, List)
assert assoc_field.type().type.of_type == String

assoc_field = ArticleType._meta.fields["recommended_reads"]
assert isinstance(assoc_field, Dynamic)
assert assoc_field.type().type == ArticleType.connection


def test_sqlalchemy_override_fields():
@convert_sqlalchemy_composite.register(CompositeFullName)
Expand Down Expand Up @@ -275,6 +287,7 @@ class Meta:
"hybrid_prop_float",
"hybrid_prop_bool",
"hybrid_prop_list",
"headlines",
]
)

Expand Down Expand Up @@ -390,6 +403,7 @@ class Meta:
"pets",
"articles",
"favorite_article",
"headlines",
]
)

Expand Down Expand Up @@ -510,7 +524,7 @@ class Meta:

assert issubclass(CustomReporterType, ObjectType)
assert CustomReporterType._meta.model == Reporter
assert len(CustomReporterType._meta.fields) == 17
assert len(CustomReporterType._meta.fields) == 18


# Test Custom SQLAlchemyObjectType with Custom Options
Expand Down
15 changes: 14 additions & 1 deletion graphene_sqlalchemy/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from typing import Any, Optional, Type, Union

import sqlalchemy
from sqlalchemy.ext.associationproxy import AssociationProxy
from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm import ColumnProperty, CompositeProperty, RelationshipProperty
from sqlalchemy.orm.exc import NoResultFound
Expand All @@ -22,6 +23,7 @@
from graphene.utils.orderedtype import OrderedType

from .converter import (
convert_sqlalchemy_association_proxy,
convert_sqlalchemy_column,
convert_sqlalchemy_composite,
convert_sqlalchemy_hybrid_method,
Expand Down Expand Up @@ -303,7 +305,7 @@ def construct_fields_and_filters(
+ [
(name, item)
for name, item in inspected_model.all_orm_descriptors.items()
if isinstance(item, hybrid_property)
if isinstance(item, hybrid_property) or isinstance(item, AssociationProxy)
]
+ inspected_model.relationships.items()
)
Expand Down Expand Up @@ -386,6 +388,17 @@ def construct_fields_and_filters(
field = convert_sqlalchemy_composite(attr, registry, resolver)
elif isinstance(attr, hybrid_property):
field = convert_sqlalchemy_hybrid_method(attr, resolver, **orm_field.kwargs)
elif isinstance(attr, AssociationProxy):
field = convert_sqlalchemy_association_proxy(
model,
attr,
obj_type,
registry,
connection_field_factory,
batching,
resolver,
**orm_field.kwargs
)
else:
raise Exception("Property type is not supported") # Should never happen

Expand Down

0 comments on commit ad9c1aa

Please sign in to comment.