Skip to content

Commit 2edeae9

Browse files
polgfrederikwrede
andauthored
feat: Support GQL interfaces for polymorphic SQLA models (#365)
* Support GQL interfaces for polymorphic SQLA models using SQLALchemyInterface and SQLAlchemyBase. fixes #313 Co-authored-by: Erik Wrede <[email protected]> Co-authored-by: Erik Wrede <[email protected]>
1 parent 8bfa1e9 commit 2edeae9

File tree

8 files changed

+447
-40
lines changed

8 files changed

+447
-40
lines changed

docs/inheritance.rst

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
Inheritance Examples
2+
====================
3+
4+
Create interfaces from inheritance relationships
5+
------------------------------------------------
6+
7+
SQLAlchemy has excellent support for class inheritance hierarchies.
8+
These hierarchies can be represented in your GraphQL schema by means
9+
of interfaces_. Much like ObjectTypes, Interfaces in
10+
Graphene-SQLAlchemy are able to infer their fields and relationships
11+
from the attributes of their underlying SQLAlchemy model:
12+
13+
.. _interfaces: https://docs.graphene-python.org/en/latest/types/interfaces/
14+
15+
.. code:: python
16+
17+
from sqlalchemy import Column, Date, Integer, String
18+
from sqlalchemy.ext.declarative import declarative_base
19+
20+
import graphene
21+
from graphene import relay
22+
from graphene_sqlalchemy import SQLAlchemyInterface, SQLAlchemyObjectType
23+
24+
Base = declarative_base()
25+
26+
class Person(Base):
27+
id = Column(Integer(), primary_key=True)
28+
type = Column(String())
29+
name = Column(String())
30+
birth_date = Column(Date())
31+
32+
__tablename__ = "person"
33+
__mapper_args__ = {
34+
"polymorphic_on": type,
35+
}
36+
37+
class Employee(Person):
38+
hire_date = Column(Date())
39+
40+
__mapper_args__ = {
41+
"polymorphic_identity": "employee",
42+
}
43+
44+
class Customer(Person):
45+
first_purchase_date = Column(Date())
46+
47+
__mapper_args__ = {
48+
"polymorphic_identity": "customer",
49+
}
50+
51+
class PersonType(SQLAlchemyInterface):
52+
class Meta:
53+
model = Person
54+
55+
class EmployeeType(SQLAlchemyObjectType):
56+
class Meta:
57+
model = Employee
58+
interfaces = (relay.Node, PersonType)
59+
60+
class CustomerType(SQLAlchemyObjectType):
61+
class Meta:
62+
model = Customer
63+
interfaces = (relay.Node, PersonType)
64+
65+
Keep in mind that `PersonType` is a `SQLAlchemyInterface`. Interfaces must
66+
be linked to an abstract Model that does not specify a `polymorphic_identity`,
67+
because we cannot return instances of interfaces from a GraphQL query.
68+
If Person specified a `polymorphic_identity`, instances of Person could
69+
be inserted into and returned by the database, potentially causing
70+
Persons to be returned to the resolvers.
71+
72+
When querying on the base type, you can refer directly to common fields,
73+
and fields on concrete implementations using the `... on` syntax:
74+
75+
76+
.. code::
77+
78+
people {
79+
name
80+
birthDate
81+
... on EmployeeType {
82+
hireDate
83+
}
84+
... on CustomerType {
85+
firstPurchaseDate
86+
}
87+
}
88+
89+
90+
Please note that by default, the "polymorphic_on" column is *not*
91+
generated as a field on types that use polymorphic inheritance, as
92+
this is considered an implentation detail. The idiomatic way to
93+
retrieve the concrete GraphQL type of an object is to query for the
94+
`__typename` field.
95+
To override this behavior, an `ORMField` needs to be created
96+
for the custom type field on the corresponding `SQLAlchemyInterface`. This is *not recommended*
97+
as it promotes abiguous schema design
98+
99+
If your SQLAlchemy model only specifies a relationship to the
100+
base type, you will need to explicitly pass your concrete implementation
101+
class to the Schema constructor via the `types=` argument:
102+
103+
.. code:: python
104+
105+
schema = graphene.Schema(..., types=[PersonType, EmployeeType, CustomerType])
106+
107+
See also: `Graphene Interfaces <https://docs.graphene-python.org/en/latest/types/interfaces/>`_

graphene_sqlalchemy/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from .fields import SQLAlchemyConnectionField
2-
from .types import SQLAlchemyObjectType
2+
from .types import SQLAlchemyInterface, SQLAlchemyObjectType
33
from .utils import get_query, get_session
44

55
__version__ = "3.0.0b3"
66

77
__all__ = [
88
"__version__",
9+
"SQLAlchemyInterface",
910
"SQLAlchemyObjectType",
1011
"SQLAlchemyConnectionField",
1112
"get_query",

graphene_sqlalchemy/registry.py

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,10 @@ def __init__(self):
1818
self._registry_unions = {}
1919

2020
def register(self, obj_type):
21+
from .types import SQLAlchemyBase
2122

22-
from .types import SQLAlchemyObjectType
23-
24-
if not isinstance(obj_type, type) or not issubclass(
25-
obj_type, SQLAlchemyObjectType
26-
):
27-
raise TypeError(
28-
"Expected SQLAlchemyObjectType, but got: {!r}".format(obj_type)
29-
)
23+
if not isinstance(obj_type, type) or not issubclass(obj_type, SQLAlchemyBase):
24+
raise TypeError("Expected SQLAlchemyBase, but got: {!r}".format(obj_type))
3025
assert obj_type._meta.registry == self, "Registry for a Model have to match."
3126
# assert self.get_type_for_model(cls._meta.model) in [None, cls], (
3227
# 'SQLAlchemy model "{}" already associated with '
@@ -38,14 +33,10 @@ def get_type_for_model(self, model):
3833
return self._registry.get(model)
3934

4035
def register_orm_field(self, obj_type, field_name, orm_field):
41-
from .types import SQLAlchemyObjectType
36+
from .types import SQLAlchemyBase
4237

43-
if not isinstance(obj_type, type) or not issubclass(
44-
obj_type, SQLAlchemyObjectType
45-
):
46-
raise TypeError(
47-
"Expected SQLAlchemyObjectType, but got: {!r}".format(obj_type)
48-
)
38+
if not isinstance(obj_type, type) or not issubclass(obj_type, SQLAlchemyBase):
39+
raise TypeError("Expected SQLAlchemyBase, but got: {!r}".format(obj_type))
4940
if not field_name or not isinstance(field_name, str):
5041
raise TypeError("Expected a field name, but got: {!r}".format(field_name))
5142
self._registry_orm_fields[obj_type][field_name] = orm_field

graphene_sqlalchemy/tests/models.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,3 +288,39 @@ class KeyedModel(Base):
288288
__tablename__ = "test330"
289289
id = Column(Integer(), primary_key=True)
290290
reporter_number = Column("% reporter_number", Numeric, key="reporter_number")
291+
292+
293+
############################################
294+
# For interfaces
295+
############################################
296+
297+
298+
class Person(Base):
299+
id = Column(Integer(), primary_key=True)
300+
type = Column(String())
301+
name = Column(String())
302+
birth_date = Column(Date())
303+
304+
__tablename__ = "person"
305+
__mapper_args__ = {
306+
"polymorphic_on": type,
307+
}
308+
309+
class NonAbstractPerson(Base):
310+
id = Column(Integer(), primary_key=True)
311+
type = Column(String())
312+
name = Column(String())
313+
birth_date = Column(Date())
314+
315+
__tablename__ = "non_abstract_person"
316+
__mapper_args__ = {
317+
"polymorphic_on": type,
318+
"polymorphic_identity": "person",
319+
}
320+
321+
class Employee(Person):
322+
hire_date = Column(Date())
323+
324+
__mapper_args__ = {
325+
"polymorphic_identity": "employee",
326+
}

graphene_sqlalchemy/tests/test_query.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,21 @@
1+
from datetime import date
2+
13
import graphene
24
from graphene.relay import Node
35

46
from ..converter import convert_sqlalchemy_composite
57
from ..fields import SQLAlchemyConnectionField
6-
from ..types import ORMField, SQLAlchemyObjectType
7-
from .models import Article, CompositeFullName, Editor, HairKind, Pet, Reporter
8+
from ..types import ORMField, SQLAlchemyInterface, SQLAlchemyObjectType
9+
from .models import (
10+
Article,
11+
CompositeFullName,
12+
Editor,
13+
Employee,
14+
HairKind,
15+
Person,
16+
Pet,
17+
Reporter,
18+
)
819
from .utils import to_std_dicts
920

1021

@@ -334,3 +345,55 @@ class Mutation(graphene.ObjectType):
334345
assert not result.errors
335346
result = to_std_dicts(result.data)
336347
assert result == expected
348+
349+
350+
def add_person_data(session):
351+
bob = Employee(name="Bob", birth_date=date(1990, 1, 1), hire_date=date(2015, 1, 1))
352+
session.add(bob)
353+
joe = Employee(name="Joe", birth_date=date(1980, 1, 1), hire_date=date(2010, 1, 1))
354+
session.add(joe)
355+
jen = Employee(name="Jen", birth_date=date(1995, 1, 1), hire_date=date(2020, 1, 1))
356+
session.add(jen)
357+
session.commit()
358+
359+
360+
def test_interface_query_on_base_type(session):
361+
add_person_data(session)
362+
363+
class PersonType(SQLAlchemyInterface):
364+
class Meta:
365+
model = Person
366+
367+
class EmployeeType(SQLAlchemyObjectType):
368+
class Meta:
369+
model = Employee
370+
interfaces = (Node, PersonType)
371+
372+
class Query(graphene.ObjectType):
373+
people = graphene.Field(graphene.List(PersonType))
374+
375+
def resolve_people(self, _info):
376+
return session.query(Person).all()
377+
378+
schema = graphene.Schema(query=Query, types=[PersonType, EmployeeType])
379+
result = schema.execute(
380+
"""
381+
query {
382+
people {
383+
__typename
384+
name
385+
birthDate
386+
... on EmployeeType {
387+
hireDate
388+
}
389+
}
390+
}
391+
"""
392+
)
393+
394+
assert not result.errors
395+
assert len(result.data["people"]) == 3
396+
assert result.data["people"][0]["__typename"] == "EmployeeType"
397+
assert result.data["people"][0]["name"] == "Bob"
398+
assert result.data["people"][0]["birthDate"] == "1990-01-01"
399+
assert result.data["people"][0]["hireDate"] == "2015-01-01"

graphene_sqlalchemy/tests/test_registry.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ def test_register_incorrect_object_type():
2828
class Spam:
2929
pass
3030

31-
re_err = "Expected SQLAlchemyObjectType, but got: .*Spam"
31+
re_err = "Expected SQLAlchemyBase, but got: .*Spam"
3232
with pytest.raises(TypeError, match=re_err):
3333
reg.register(Spam)
3434

@@ -51,7 +51,7 @@ def test_register_orm_field_incorrect_types():
5151
class Spam:
5252
pass
5353

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

0 commit comments

Comments
 (0)