Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support GQL interfaces for polymorphic SQLA models #365

Merged
merged 9 commits into from
Nov 28, 2022
107 changes: 107 additions & 0 deletions docs/inheritance.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
Inheritance Examples
====================

Create interfaces from inheritance relationships
------------------------------------------------

SQLAlchemy has excellent support for class inheritance hierarchies.
These hierarchies can be represented in your GraphQL schema by means
of interfaces_. Much like ObjectTypes, Interfaces in
Graphene-SQLAlchemy are able to infer their fields and relationships
from the attributes of their underlying SQLAlchemy model:

.. _interfaces: https://docs.graphene-python.org/en/latest/types/interfaces/

.. code:: python

from sqlalchemy import Column, Date, Integer, String
from sqlalchemy.ext.declarative import declarative_base

import graphene
from graphene import relay
from graphene_sqlalchemy import SQLAlchemyInterface, SQLAlchemyObjectType

Base = declarative_base()

class Person(Base):
id = Column(Integer(), primary_key=True)
type = Column(String())
name = Column(String())
birth_date = Column(Date())

__tablename__ = "person"
__mapper_args__ = {
"polymorphic_on": type,
}

class Employee(Person):
hire_date = Column(Date())

__mapper_args__ = {
"polymorphic_identity": "employee",
}

class Customer(Person):
first_purchase_date = Column(Date())

__mapper_args__ = {
"polymorphic_identity": "customer",
}

class PersonType(SQLAlchemyInterface):
class Meta:
model = Person

class EmployeeType(SQLAlchemyObjectType):
class Meta:
model = Employee
interfaces = (relay.Node, PersonType)

class CustomerType(SQLAlchemyObjectType):
class Meta:
model = Customer
interfaces = (relay.Node, PersonType)

Keep in mind that `PersonType` is a `SQLAlchemyInterface`. Interfaces must
be linked to an abstract Model that does not specify a `polymorphic_identity`,
because we cannot return instances of interfaces from a GraphQL query.
If Person specified a `polymorphic_identity`, instances of Person could
be inserted into and returned by the database, potentially causing
Persons to be returned to the resolvers.

When querying on the base type, you can refer directly to common fields,
and fields on concrete implementations using the `... on` syntax:


.. code::

people {
name
birthDate
... on EmployeeType {
hireDate
}
... on CustomerType {
firstPurchaseDate
}
}


Please note that by default, the "polymorphic_on" column is *not*
generated as a field on types that use polymorphic inheritance, as
this is considered an implentation detail. The idiomatic way to
retrieve the concrete GraphQL type of an object is to query for the
`__typename` field.
To override this behavior, an `ORMField` needs to be created
for the custom type field on the corresponding `SQLAlchemyInterface`. This is *not recommended*
as it promotes abiguous schema design

If your SQLAlchemy model only specifies a relationship to the
base type, you will need to explicitly pass your concrete implementation
class to the Schema constructor via the `types=` argument:

.. code:: python

schema = graphene.Schema(..., types=[PersonType, EmployeeType, CustomerType])

See also: `Graphene Interfaces <https://docs.graphene-python.org/en/latest/types/interfaces/>`_
3 changes: 2 additions & 1 deletion graphene_sqlalchemy/__init__.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from .fields import SQLAlchemyConnectionField
from .types import SQLAlchemyObjectType
from .types import SQLAlchemyInterface, SQLAlchemyObjectType
from .utils import get_query, get_session

__version__ = "3.0.0b3"

__all__ = [
"__version__",
"SQLAlchemyInterface",
"SQLAlchemyObjectType",
"SQLAlchemyConnectionField",
"get_query",
Expand Down
21 changes: 6 additions & 15 deletions graphene_sqlalchemy/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,10 @@ def __init__(self):
self._registry_unions = {}

def register(self, obj_type):
from .types import SQLAlchemyBase

from .types import SQLAlchemyObjectType

if not isinstance(obj_type, type) or not issubclass(
obj_type, SQLAlchemyObjectType
):
raise TypeError(
"Expected SQLAlchemyObjectType, but got: {!r}".format(obj_type)
)
if not isinstance(obj_type, type) or not issubclass(obj_type, SQLAlchemyBase):
raise TypeError("Expected SQLAlchemyBase, but got: {!r}".format(obj_type))
assert obj_type._meta.registry == self, "Registry for a Model have to match."
# assert self.get_type_for_model(cls._meta.model) in [None, cls], (
# 'SQLAlchemy model "{}" already associated with '
Expand All @@ -38,14 +33,10 @@ def get_type_for_model(self, model):
return self._registry.get(model)

def register_orm_field(self, obj_type, field_name, orm_field):
from .types import SQLAlchemyObjectType
from .types import SQLAlchemyBase

if not isinstance(obj_type, type) or not issubclass(
obj_type, SQLAlchemyObjectType
):
raise TypeError(
"Expected SQLAlchemyObjectType, but got: {!r}".format(obj_type)
)
if not isinstance(obj_type, type) or not issubclass(obj_type, SQLAlchemyBase):
raise TypeError("Expected SQLAlchemyBase, but got: {!r}".format(obj_type))
if not field_name or not isinstance(field_name, str):
raise TypeError("Expected a field name, but got: {!r}".format(field_name))
self._registry_orm_fields[obj_type][field_name] = orm_field
Expand Down
36 changes: 36 additions & 0 deletions graphene_sqlalchemy/tests/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,3 +288,39 @@ class KeyedModel(Base):
__tablename__ = "test330"
id = Column(Integer(), primary_key=True)
reporter_number = Column("% reporter_number", Numeric, key="reporter_number")


############################################
# For interfaces
############################################


class Person(Base):
id = Column(Integer(), primary_key=True)
type = Column(String())
name = Column(String())
birth_date = Column(Date())

__tablename__ = "person"
__mapper_args__ = {
"polymorphic_on": type,
}

class NonAbstractPerson(Base):
id = Column(Integer(), primary_key=True)
type = Column(String())
name = Column(String())
birth_date = Column(Date())

__tablename__ = "non_abstract_person"
__mapper_args__ = {
"polymorphic_on": type,
"polymorphic_identity": "person",
}

class Employee(Person):
hire_date = Column(Date())

__mapper_args__ = {
"polymorphic_identity": "employee",
}
67 changes: 65 additions & 2 deletions graphene_sqlalchemy/tests/test_query.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
from datetime import date

import graphene
from graphene.relay import Node

from ..converter import convert_sqlalchemy_composite
from ..fields import SQLAlchemyConnectionField
from ..types import ORMField, SQLAlchemyObjectType
from .models import Article, CompositeFullName, Editor, HairKind, Pet, Reporter
from ..types import ORMField, SQLAlchemyInterface, SQLAlchemyObjectType
from .models import (
Article,
CompositeFullName,
Editor,
Employee,
HairKind,
Person,
Pet,
Reporter,
)
from .utils import to_std_dicts


Expand Down Expand Up @@ -334,3 +345,55 @@ class Mutation(graphene.ObjectType):
assert not result.errors
result = to_std_dicts(result.data)
assert result == expected


def add_person_data(session):
bob = Employee(name="Bob", birth_date=date(1990, 1, 1), hire_date=date(2015, 1, 1))
session.add(bob)
joe = Employee(name="Joe", birth_date=date(1980, 1, 1), hire_date=date(2010, 1, 1))
session.add(joe)
jen = Employee(name="Jen", birth_date=date(1995, 1, 1), hire_date=date(2020, 1, 1))
session.add(jen)
session.commit()


def test_interface_query_on_base_type(session):
add_person_data(session)

class PersonType(SQLAlchemyInterface):
class Meta:
model = Person

class EmployeeType(SQLAlchemyObjectType):
class Meta:
model = Employee
interfaces = (Node, PersonType)

class Query(graphene.ObjectType):
people = graphene.Field(graphene.List(PersonType))

def resolve_people(self, _info):
return session.query(Person).all()

schema = graphene.Schema(query=Query, types=[PersonType, EmployeeType])
result = schema.execute(
"""
query {
people {
__typename
name
birthDate
... on EmployeeType {
hireDate
}
}
}
"""
)

assert not result.errors
assert len(result.data["people"]) == 3
assert result.data["people"][0]["__typename"] == "EmployeeType"
assert result.data["people"][0]["name"] == "Bob"
assert result.data["people"][0]["birthDate"] == "1990-01-01"
assert result.data["people"][0]["hireDate"] == "2015-01-01"
4 changes: 2 additions & 2 deletions graphene_sqlalchemy/tests/test_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ def test_register_incorrect_object_type():
class Spam:
pass

re_err = "Expected SQLAlchemyObjectType, but got: .*Spam"
re_err = "Expected SQLAlchemyBase, but got: .*Spam"
with pytest.raises(TypeError, match=re_err):
reg.register(Spam)

Expand All @@ -51,7 +51,7 @@ def test_register_orm_field_incorrect_types():
class Spam:
pass

re_err = "Expected SQLAlchemyObjectType, but got: .*Spam"
re_err = "Expected SQLAlchemyBase, but got: .*Spam"
with pytest.raises(TypeError, match=re_err):
reg.register_orm_field(Spam, "name", Pet.name)

Expand Down
Loading