diff --git a/graphene_sqlalchemy/converter.py b/graphene_sqlalchemy/converter.py index 5d75984b..061f9abc 100644 --- a/graphene_sqlalchemy/converter.py +++ b/graphene_sqlalchemy/converter.py @@ -7,7 +7,8 @@ from sqlalchemy import types from sqlalchemy.dialects import postgresql -from sqlalchemy.orm import interfaces, strategies +from sqlalchemy.orm import (ColumnProperty, RelationshipProperty, class_mapper, + interfaces, strategies) from graphene import (ID, Boolean, Date, DateTime, Dynamic, Enum, Field, Float, Int, List, String, Time) @@ -49,6 +50,39 @@ 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'] = 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, not supported + + return Dynamic(dynamic_type) + + def convert_sqlalchemy_relationship(relationship_prop, obj_type, connection_field_factory, batching, orm_field_name, **field_kwargs): """ diff --git a/graphene_sqlalchemy/tests/models.py b/graphene_sqlalchemy/tests/models.py index e41adb51..06254b65 100644 --- a/graphene_sqlalchemy/tests/models.py +++ b/graphene_sqlalchemy/tests/models.py @@ -7,6 +7,7 @@ from sqlalchemy import (Column, Date, Enum, ForeignKey, Integer, String, Table, func, select) +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 column_property, composite, mapper, relationship @@ -103,6 +104,8 @@ def hybrid_prop_list(self) -> List[int]: composite_prop = composite(CompositeFullName, first_name, last_name, doc="Composite") + headlines = association_proxy('articles', 'headline') + class Article(Base): __tablename__ = "articles" @@ -110,6 +113,7 @@ class Article(Base): headline = Column(String(100)) pub_date = Column(Date()) reporter_id = Column(Integer(), ForeignKey("reporters.id")) + recommended_reads = association_proxy('reporter', 'articles') class ReflectedEditor(type): diff --git a/graphene_sqlalchemy/tests/test_converter.py b/graphene_sqlalchemy/tests/test_converter.py index 70e11713..f4fd88b4 100644 --- a/graphene_sqlalchemy/tests/test_converter.py +++ b/graphene_sqlalchemy/tests/test_converter.py @@ -16,7 +16,8 @@ from graphene.types.json import JSONString from graphene.types.structures import List, Structure -from ..converter import (convert_sqlalchemy_column, +from ..converter import (convert_sqlalchemy_association_proxy, + convert_sqlalchemy_column, convert_sqlalchemy_composite, convert_sqlalchemy_relationship) from ..fields import (UnsortedSQLAlchemyConnectionField, @@ -290,6 +291,41 @@ 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, + ) + assert isinstance(dynamic_field, graphene.Dynamic) + assert dynamic_field.get_type().type.of_type == ArticleType + + def test_should_postgresql_uuid_convert(): assert get_field(postgresql.UUID()).type == graphene.String diff --git a/graphene_sqlalchemy/tests/test_query.py b/graphene_sqlalchemy/tests/test_query.py index 39140814..80418824 100644 --- a/graphene_sqlalchemy/tests/test_query.py +++ b/graphene_sqlalchemy/tests/test_query.py @@ -57,6 +57,7 @@ def resolve_reporters(self, _info): columnProp hybridProp compositeProp + headlines } reporters { firstName @@ -69,6 +70,7 @@ 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 9a2e992d..8d7dd282 100644 --- a/graphene_sqlalchemy/tests/test_types.py +++ b/graphene_sqlalchemy/tests/test_types.py @@ -74,6 +74,11 @@ class Meta: model = Article interfaces = (Node,) + class PetType(SQLAlchemyObjectType): + class Meta: + model = Pet + interfaces = (Node,) + assert sorted(list(ReporterType._meta.fields.keys())) == sorted([ # Columns "column_prop", # SQLAlchemy retuns column properties first @@ -96,6 +101,8 @@ class Meta: "pets", "articles", "favorite_article", + # AssociationProxy + "headlines", ]) # column @@ -163,6 +170,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) @@ -231,6 +248,7 @@ class Meta: "hybrid_prop_float", "hybrid_prop_bool", "hybrid_prop_list", + "headlines", ]) first_name_field = ReporterType._meta.fields['first_name'] @@ -342,6 +360,7 @@ class Meta: "pets", "articles", "favorite_article", + "headlines", ]) @@ -447,7 +466,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 ac69b697..ecbd492d 100644 --- a/graphene_sqlalchemy/types.py +++ b/graphene_sqlalchemy/types.py @@ -1,6 +1,7 @@ from collections import OrderedDict import sqlalchemy +from sqlalchemy.ext.associationproxy import AssociationProxy from sqlalchemy.ext.hybrid import hybrid_property from sqlalchemy.orm import (ColumnProperty, CompositeProperty, RelationshipProperty) @@ -12,7 +13,8 @@ from graphene.types.utils import yank_fields_from_attrs from graphene.utils.orderedtype import OrderedType -from .converter import (convert_sqlalchemy_column, +from .converter import (convert_sqlalchemy_association_proxy, + convert_sqlalchemy_column, convert_sqlalchemy_composite, convert_sqlalchemy_hybrid_method, convert_sqlalchemy_relationship) @@ -114,6 +116,8 @@ def construct_fields( inspected_model.composites.items() + [(name, item) for name, item in inspected_model.all_orm_descriptors.items() if isinstance(item, hybrid_property)] + + [(name, item) for name, item in inspected_model.all_orm_descriptors.items() + if isinstance(item, AssociationProxy)] + inspected_model.relationships.items() ) @@ -172,6 +176,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