Skip to content
This repository has been archived by the owner on Jun 6, 2024. It is now read-only.

Support for polymorphic relationships #367

Open
wants to merge 4 commits into
base: dev
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AUTHORS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,4 @@ Contributors (chronological)
- Areeb Jamal `@iamareebjamal <https://github.com/iamareebjamal>`_
- Suren Khorenyan `@mahenzon <https://github.com/mahenzon>`_
- Karthikeyan Singaravelan `@tirkarthi <https://github.com/tirkarthi>`_
- Max Buck `@buckmaxwell <https://github.com/buckmaxwell>`_
58 changes: 58 additions & 0 deletions docs/quickstart.rst
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,64 @@ To serialize links, pass a URL format string and a dictionary of keyword argumen
# }
# }

It is possible to create a polymorphic relationship by having the serialized model define __jsonapi_type__. Polymorphic relationships are supported by json-api and by many front end frameworks that implement it like `ember <https://guides.emberjs.com/release/models/relationships/#toc_polymorphism>`_.

.. code-block:: python

class PaymentMethod(Bunch):
__jsonapi_type__ = "payment-methods"


class PaymentMethodCreditCard(PaymentMethod, Bunch):
__jsonapi_type__ = "payment-methods-cc"


class PaymentMethodPaypal(PaymentMethod, Bunch):
__jsonapi_type__ = "payment-methods-paypal"


class User:
def __init__(self, id):
self.id = id
self.payment_methods = get_payment_methods(id)

A polymorphic Schema can be created using `OneOfSchema <https://github.com/marshmallow-code/marshmallow-oneofschema>`_. For example, a user may have multiple payment methods with slightly different attributes. Note that OneOfSchema must be separately installed and that there are other ways of creating a polymorphic Schema, this is merely an example.


.. code-block:: python

class PaymentMethodCreditCardSchema(Schema):
id = fields.Str()
last_4 = fields.Str()

class Meta:
type_ = "payment-methods-cc"


class PaymentMethodPaypalSchema(Schema):
id = fields.Str()
linked_email = fields.Str()

class Meta:
type_ = "payment-methods-paypal"


class PaymentMethodSchema(Schema, OneOfSchema):
id = fields.Str()
type_schemas = {
"payment-methods-cc": PaymentMethodCreditCardSchema,
"payment-methods-paypal": PaymentMethodPaypalSchema,
}

def get_obj_type(self, obj):
if isinstance(obj, PaymentMethod):
return obj.__jsonapi_type__
else:
raise Exception("Unknown object type: {}".format(obj.__class__.__name__))

class Meta:
type_ = "payment-methods"

Resource linkages
-----------------

Expand Down
13 changes: 11 additions & 2 deletions marshmallow_jsonapi/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ class Relationship(BaseRelationship):

This field is read-only by default.

The type_ keyword argument will be ignored in favor of a __jsonapi_type__ attribute on the serialized
resource. This allows support for polymorphic relationships.

:param str related_url: Format string for related resource links.
:param dict related_url_kwargs: Replacement fields for `related_url`. String arguments
enclosed in `< >` will be interpreted as attributes to pull from the target object.
Expand Down Expand Up @@ -173,12 +176,12 @@ def get_self_url(self, obj):
def get_resource_linkage(self, value):
if self.many:
resource_object = [
{"type": self.type_, "id": _stringify(self._get_id(each))}
{"type": self._get_type(each), "id": _stringify(self._get_id(each))}
for each in value
]
else:
resource_object = {
"type": self.type_,
"type": self._get_type(value),
"id": _stringify(self._get_id(value)),
}
return resource_object
Expand Down Expand Up @@ -289,6 +292,12 @@ def _get_id(self, value):
else:
return get_value(value, self.id_field, value)

def _get_type(self, obj):
try:
return obj.__jsonapi_type__
except AttributeError:
return self.type_


class DocumentMeta(Field):
"""Field which serializes to a "meta object" within a document’s “top level”.
Expand Down
8 changes: 7 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@

INSTALL_REQUIRES = ("marshmallow>=2.15.2",)
EXTRAS_REQUIRE = {
"tests": ["pytest", "mock", "faker==4.18.0", "Flask==1.1.2"],
"tests": [
"pytest",
"mock",
"faker==4.18.0",
"Flask==1.1.2",
"marshmallow-oneofschema==2.1.0",
],
"lint": ["flake8==3.8.4", "flake8-bugbear==20.11.1", "pre-commit~=2.0"],
}
EXTRAS_REQUIRE["dev"] = EXTRAS_REQUIRE["tests"] + EXTRAS_REQUIRE["lint"] + ["tox"]
Expand Down
71 changes: 71 additions & 0 deletions tests/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from marshmallow import validate

from marshmallow_jsonapi import Schema, fields
from marshmallow_oneofschema import OneOfSchema

fake = Factory.create()

Expand All @@ -30,6 +31,76 @@ class Keyword(Bunch):
pass


class User(Bunch):
pass


class PaymentMethod(Bunch):
"""Base Class for Payment Methods"""

__jsonapi_type__ = "payment-methods"


class PaymentMethodCreditCard(PaymentMethod, Bunch):
__jsonapi_type__ = "payment-methods-cc"


class PaymentMethodPaypal(PaymentMethod, Bunch):
__jsonapi_type__ = "payment-methods-paypal"


class PaymentMethodCreditCardSchema(Schema):
id = fields.Str()
last_4 = fields.Str()

class Meta:
type_ = "payment-methods-cc"


class PaymentMethodPaypalSchema(Schema):
id = fields.Str()
linked_email = fields.Str()

class Meta:
type_ = "payment-methods-paypal"


class PaymentMethodSchema(Schema, OneOfSchema):
"""Using https://github.com/marshmallow-code/marshmallow-oneofschema is one way to have a polymorphic schema"""

id = fields.Str()

type_schemas = {
"payment-methods-cc": PaymentMethodCreditCardSchema,
"payment-methods-paypal": PaymentMethodPaypalSchema,
}

def get_obj_type(self, obj):
if isinstance(obj, PaymentMethod):
return obj.__jsonapi_type__
else:
raise Exception(f"Unknown object type: {obj.__class__.__name__}")

class Meta:
type_ = "payment-methods"


class UserSchema(Schema):
id = fields.Str()
first_name = fields.Str(required=True)
last_name = fields.Str(required=True)

payment_methods = fields.Relationship(
schema=PaymentMethodSchema,
include_resource_linkage=True,
many=True,
type_="payment-methods",
)

class Meta:
type_ = "users"


class AuthorSchema(Schema):
id = fields.Str()
first_name = fields.Str(required=True)
Expand Down
37 changes: 36 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import pytest

from tests.base import Author, Post, Comment, Keyword, fake
from tests.base import (
Author,
Post,
Comment,
Keyword,
User,
PaymentMethodCreditCard,
PaymentMethodPaypal,
fake,
)


def make_author():
Expand Down Expand Up @@ -35,6 +44,32 @@ def make_keyword():
return Keyword(keyword=fake.domain_word())


def make_payment_methods():
return [
PaymentMethodCreditCard(id=fake.random_int(), last_4="1335"),
PaymentMethodPaypal(id=fake.random_int(), linked_email="[email protected]"),
]


def make_user():
return User(
id=fake.random_int(),
first_name=fake.first_name(),
last_name=fake.last_name(),
payment_methods=make_payment_methods(),
)


@pytest.fixture()
def user():
return make_user()


@pytest.fixture()
def payment_methods():
return make_payment_methods()


@pytest.fixture()
def author():
return make_author()
Expand Down
37 changes: 37 additions & 0 deletions tests/test_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
PostSchema,
PolygonSchema,
ArticleSchema,
UserSchema,
)


Expand Down Expand Up @@ -71,6 +72,33 @@ def test_dump_many(self, authors):
assert attribs["first_name"] == authors[0].first_name
assert attribs["last_name"] == authors[0].last_name

def test_dump_single_with_polymorphic_relationship(self, user):
data = UserSchema(include_data=("payment_methods",)).dump(user)
assert "data" in data
assert type(data["data"]["relationships"]["payment_methods"]["data"]) is list

first_payment_method = data["data"]["relationships"]["payment_methods"]["data"][
0
]
assert first_payment_method["id"] == str(user.payment_methods[0].id)
assert first_payment_method["type"] == user.payment_methods[0].__jsonapi_type__

second_payment_method = data["data"]["relationships"]["payment_methods"][
"data"
][1]
assert second_payment_method["id"] == str(user.payment_methods[1].id)
assert second_payment_method["type"] == user.payment_methods[1].__jsonapi_type__

included_resources = data["included"]
assert (
included_resources[0]["attributes"]["last_4"]
== user.payment_methods[0].last_4
)
assert (
included_resources[1]["attributes"]["linked_email"]
== user.payment_methods[1].linked_email
)

def test_self_link_single(self, author):
data = AuthorSchema().dump(author)
assert "links" in data
Expand Down Expand Up @@ -150,6 +178,15 @@ def test_include_data_with_all_relations(self, post):
}
assert included_comments_author_ids == expected_comments_author_ids

def test_include_data_with_polymorphic_relation(self, user):
data = UserSchema(include_data=("payment_methods",)).dump(user)

assert "included" in data
assert len(data["included"]) == 2
for included in data["included"]:
assert included["id"]
assert included["type"] in ("payment-methods-cc", "payment-methods-paypal")

def test_include_no_data(self, post):
data = PostSchema(include_data=()).dump(post)
assert "included" not in data
Expand Down