From 07b20a8f7b60d3cd3ecb4ec0bb0a20008445f1fa Mon Sep 17 00:00:00 2001 From: dpepper Date: Wed, 12 Feb 2020 14:53:51 -0800 Subject: [PATCH] association_proxy support --- graphene_sqlalchemy/converter.py | 58 ++++++++++++++++++++- graphene_sqlalchemy/tests/models.py | 4 ++ graphene_sqlalchemy/tests/test_converter.py | 29 ++++++++++- graphene_sqlalchemy/types.py | 14 ++++- 4 files changed, 100 insertions(+), 5 deletions(-) diff --git a/graphene_sqlalchemy/converter.py b/graphene_sqlalchemy/converter.py index ae90001b..b2a641c7 100644 --- a/graphene_sqlalchemy/converter.py +++ b/graphene_sqlalchemy/converter.py @@ -1,9 +1,13 @@ from enum import EnumMeta from singledispatch import singledispatch -from sqlalchemy import types +from sqlalchemy import inspect, types from sqlalchemy.dialects import postgresql -from sqlalchemy.orm import interfaces +from sqlalchemy.ext.associationproxy import AssociationProxy +from sqlalchemy.ext.hybrid import hybrid_property +from sqlalchemy.orm import (ColumnProperty, CompositeProperty, + interfaces, RelationshipProperty) +from sqlalchemy.orm.attributes import InstrumentedAttribute from graphene import (ID, Boolean, Dynamic, Enum, Field, Float, Int, List, String) @@ -18,6 +22,7 @@ ChoiceType = JSONType = ScalarListType = TSVectorType = object + def get_column_doc(column): return getattr(column, "doc", None) @@ -26,6 +31,55 @@ def is_column_nullable(column): return bool(getattr(column, "nullable", True)) +def convert_sqlalchemy_association_proxy(association_prop, registry, connection_field_factory, resolver, **field_kwargs): + model = association_prop.target_class + + attr = getattr(model, association_prop.value_attr) + if isinstance(attr, InstrumentedAttribute): + attr = inspect(attr).property + + def dynamic_type(): + if isinstance(attr, AssociationProxy): + return convert_sqlalchemy_association_proxy( + attr, + registry, + resolver, + **field_kwargs + ) + elif isinstance(attr, ColumnProperty): + return convert_sqlalchemy_column( + attr, + registry, + resolver, + **field_kwargs + ) + elif isinstance(attr, CompositeProperty): + return convert_sqlalchemy_composite( + attr, + registry, + resolver + ) + elif isinstance(attr, RelationshipProperty): + return convert_sqlalchemy_relationship( + attr, + registry, + connection_field_factory, + resolver, + **field_kwargs + # resolve Dynamic type + ).get_type() + elif isinstance(attr, hybrid_property): + return convert_sqlalchemy_hybrid_method( + attr, + resolver, + **field_kwargs + ) + + raise NotImplementedError(attr) + + return Dynamic(dynamic_type) + + def convert_sqlalchemy_relationship(relationship_prop, registry, connection_field_factory, resolver, **field_kwargs): direction = relationship_prop.direction model = relationship_prop.mapper.entity diff --git a/graphene_sqlalchemy/tests/models.py b/graphene_sqlalchemy/tests/models.py index 88e992b9..d7b089ce 100644 --- a/graphene_sqlalchemy/tests/models.py +++ b/graphene_sqlalchemy/tests/models.py @@ -4,6 +4,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 @@ -65,6 +66,8 @@ class Reporter(Base): articles = relationship("Article", backref="reporter") favorite_article = relationship("Article", uselist=False) + pet_names = association_proxy('pets', 'name') + @hybrid_property def hybrid_prop(self): return self.first_name @@ -82,6 +85,7 @@ class Article(Base): headline = Column(String(100)) pub_date = Column(Date()) reporter_id = Column(Integer(), ForeignKey("reporters.id")) + reporter_pets = association_proxy('reporter', 'pets') class ReflectedEditor(type): diff --git a/graphene_sqlalchemy/tests/test_converter.py b/graphene_sqlalchemy/tests/test_converter.py index e8051a18..20144473 100644 --- a/graphene_sqlalchemy/tests/test_converter.py +++ b/graphene_sqlalchemy/tests/test_converter.py @@ -13,7 +13,8 @@ from graphene.types.datetime import DateTime from graphene.types.json import JSONString -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, @@ -291,6 +292,32 @@ class Meta: assert graphene_type.type == A +def test_should_convert_association_proxy(): + class P(SQLAlchemyObjectType): + class Meta: + model = Pet + + dynamic_field = convert_sqlalchemy_association_proxy( + Reporter.pet_names, + P._meta.registry, + default_connection_field_factory, + mock_resolver, + ) + assert isinstance(dynamic_field, graphene.Dynamic) + graphene_type = dynamic_field.get_type() + assert isinstance(graphene_type, graphene.Field) + assert graphene_type.type == graphene.String + + dynamic_field = convert_sqlalchemy_association_proxy( + Article.reporter_pets, + P._meta.registry, + default_connection_field_factory, + mock_resolver, + ) + assert isinstance(dynamic_field.get_type().type, graphene.List) + assert dynamic_field.get_type().type.of_type == P + + def test_should_postgresql_uuid_convert(): assert get_field(postgresql.UUID()).type == graphene.String diff --git a/graphene_sqlalchemy/types.py b/graphene_sqlalchemy/types.py index ef189b38..2d4fcd91 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, strategies) @@ -14,7 +15,8 @@ from graphene.utils.orderedtype import OrderedType from .batching import get_batch_resolver -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) @@ -110,7 +112,7 @@ def construct_fields( inspected_model.column_attrs.items() + inspected_model.composites.items() + [(name, item) for name, item in inspected_model.all_orm_descriptors.items() - if isinstance(item, hybrid_property)] + + if isinstance(item, (AssociationProxy, hybrid_property))] + inspected_model.relationships.items() ) @@ -186,6 +188,14 @@ def construct_fields( custom_resolver or _get_attr_resolver(obj_type, orm_field_name, attr_name), **orm_field.kwargs ) + elif isinstance(attr, AssociationProxy): + field = convert_sqlalchemy_association_proxy( + attr.for_class(model), + registry, + connection_field_factory, + custom_resolver or _get_attr_resolver(obj_type, orm_field_name, attr_name), + **orm_field.kwargs + ) else: raise Exception('Property type is not supported') # Should never happen