diff --git a/graphene_sqlalchemy/converter.py b/graphene_sqlalchemy/converter.py index 8c7cd7a..84c7886 100644 --- a/graphene_sqlalchemy/converter.py +++ b/graphene_sqlalchemy/converter.py @@ -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 @@ -101,6 +107,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, diff --git a/graphene_sqlalchemy/tests/models.py b/graphene_sqlalchemy/tests/models.py index b638b5d..c871bed 100644 --- a/graphene_sqlalchemy/tests/models.py +++ b/graphene_sqlalchemy/tests/models.py @@ -17,6 +17,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 @@ -78,6 +79,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" @@ -135,6 +148,8 @@ def hybrid_prop_list(self) -> List[int]: CompositeFullName, first_name, last_name, doc="Composite" ) + headlines = association_proxy("articles", "headline") + class Article(Base): __tablename__ = "articles" @@ -145,6 +160,7 @@ class Article(Base): readers = relationship( "Reader", secondary="articles_readers", back_populates="articles" ) + recommended_reads = association_proxy("reporter", "articles") class Reader(Base): diff --git a/graphene_sqlalchemy/tests/test_converter.py b/graphene_sqlalchemy/tests/test_converter.py index 884af7d..8406924 100644 --- a/graphene_sqlalchemy/tests/test_converter.py +++ b/graphene_sqlalchemy/tests/test_converter.py @@ -25,6 +25,7 @@ ) from .utils import wrap_select_func from ..converter import ( + convert_sqlalchemy_association_proxy, convert_sqlalchemy_column, convert_sqlalchemy_composite, convert_sqlalchemy_hybrid_method, @@ -41,6 +42,7 @@ CompositeFullName, CustomColumnModel, Pet, + ProxiedReporter, Reporter, ShoppingCart, ShoppingCartItem, @@ -650,6 +652,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 diff --git a/graphene_sqlalchemy/tests/test_query.py b/graphene_sqlalchemy/tests/test_query.py index 055a87f..168a82f 100644 --- a/graphene_sqlalchemy/tests/test_query.py +++ b/graphene_sqlalchemy/tests/test_query.py @@ -80,6 +80,7 @@ async def resolve_reporters(self, _info): columnProp hybridProp compositeProp + headlines } reporters { firstName @@ -92,6 +93,7 @@ async def resolve_reporters(self, _info): "hybridProp": "John", "columnProp": 2, "compositeProp": "John Doe", + "headlines": ["Hi!"], }, "reporters": [{"firstName": "John"}, {"firstName": "Jane"}], } diff --git a/graphene_sqlalchemy/tests/test_types.py b/graphene_sqlalchemy/tests/test_types.py index 3de443d..e5b154c 100644 --- a/graphene_sqlalchemy/tests/test_types.py +++ b/graphene_sqlalchemy/tests/test_types.py @@ -138,6 +138,8 @@ class Meta: "pets", "articles", "favorite_article", + # AssociationProxy + "headlines", ] ) @@ -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) @@ -275,6 +287,7 @@ class Meta: "hybrid_prop_float", "hybrid_prop_bool", "hybrid_prop_list", + "headlines", ] ) @@ -390,6 +403,7 @@ class Meta: "pets", "articles", "favorite_article", + "headlines", ] ) @@ -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 diff --git a/graphene_sqlalchemy/types.py b/graphene_sqlalchemy/types.py index 66db1e6..dac5b15 100644 --- a/graphene_sqlalchemy/types.py +++ b/graphene_sqlalchemy/types.py @@ -3,6 +3,7 @@ from typing import Any 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 @@ -16,6 +17,7 @@ from graphene.utils.orderedtype import OrderedType from .converter import ( + convert_sqlalchemy_association_proxy, convert_sqlalchemy_column, convert_sqlalchemy_composite, convert_sqlalchemy_hybrid_method, @@ -152,7 +154,7 @@ def construct_fields( + [ (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() ) @@ -230,6 +232,17 @@ def construct_fields( 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