Skip to content

Commit

Permalink
Merge pull request #536 from jfinkels/polymorphic-models
Browse files Browse the repository at this point in the history
Adds single table inheritance support for models
  • Loading branch information
jfinkels committed Jun 9, 2016
2 parents 6e1695b + 54d6ba4 commit f91a6b9
Show file tree
Hide file tree
Showing 15 changed files with 814 additions and 122 deletions.
6 changes: 5 additions & 1 deletion CHANGES
Expand Up @@ -18,13 +18,17 @@ Version 1.0.0b2-dev
Not yet released.

- Changes serialization/deserialization to class-based implementation instead
of a function-based implementation.
of a function-based implementation. This also adds support for serialization
of heterogeneous collections.
- :issue:`7`: allows filtering before function evaluation.
- :issue:`49`: deserializers now expect a complete JSON API document.
- :issue:`200`: be smarter about determining the ``collection_name`` for
polymorphic models defined with single-table inheritance.
- :issue:`253`: don't assign to callable attributes of models.
- :issue:`481,488`: added negation (``not``) operator for search.
- :issue:`492`: support JSON API recommended "simple" filtering.
- :issue:`508`: flush the session before postprocessors, and commit after.
- :issue:`536`: adds support for single-table inheritance.

Version 1.0.0b1
---------------
Expand Down
15 changes: 6 additions & 9 deletions docs/basicusage.rst
@@ -1,7 +1,3 @@
.. currentmodule:: flask.ext.restless

.. _basicusage:

Creating API endpoints
======================

Expand All @@ -10,10 +6,11 @@ SQLAlchemy or Flask-SQLALchemy. The basic setup in either case is nearly the
same.

If you have defined your models with Flask-SQLAlchemy, first, create your
:class:`~flask.Flask` object, :class:`~flask.ext.sqlalchemy.SQLAlchemy` object,
and model classes as usual but with one additional restriction: each model must
have a primary key column named ``id`` of type :class:`sqlalchemy.Integer` or
type :class:`sqlalchemy.Unicode`.
:class:`~flask.Flask` object, :class:`~flask_sqlalchemy.SQLAlchemy` object, and
model classes as usual but with one additional restriction: each model must
have a primary key column named ``id`` of type
:class:`~sqlalchemy.sql.sqltypes.Integer` or type
:class:`~sqlalchemy.sql.sqltypes.Unicode`.

.. sourcecode:: python

Expand Down Expand Up @@ -67,7 +64,7 @@ If you are using pure SQLAlchemy::
Base.metadata.create_all()

Second, instantiate an :class:`APIManager` object with the
:class:`~flask.Flask` and :class:`~flask.ext.sqlalchemy.SQLAlchemy` objects::
:class:`~flask.Flask` and :class:`~flask_sqlalchemy.SQLAlchemy` objects::

from flask.ext.restless import APIManager

Expand Down
2 changes: 1 addition & 1 deletion docs/conf.py
Expand Up @@ -261,7 +261,7 @@
intersphinx_mapping = {
'python': ('http://docs.python.org/', None),
'flask': ('http://flask.pocoo.org/docs', None),
'sqlalchemy': ('http://sqlalchemy.org/docs', None),
'sqlalchemy': ('http://docs.sqlalchemy.org/en/latest', None),
'flasksqlalchemy': ('http://flask-sqlalchemy.pocoo.org', None),
'flasklogin': ('https://flask-login.readthedocs.org/en/latest', None)
}
Expand Down
219 changes: 219 additions & 0 deletions docs/databasesetup.rst
@@ -0,0 +1,219 @@
Common SQLAlchemy setups
========================

Flask-Restless automatically handles SQLAlchemy models defined with
`association proxies`_ and `polymorphism`_.

.. _association proxies: http://docs.sqlalchemy.org/en/latest/orm/extensions/associationproxy.html
.. _polymorphism: http://docs.sqlalchemy.org/en/latest/orm/inheritance.html

Association proxies
-------------------

Flask-Restless handles many-to-many relationships transparently through
association proxies. It exposes the remote table in the ``relationships``
element of a resource in the JSON document and hides the intermediate table.

For example, consider a setup where there are articles and tags in a
many-to-many relationship::

from sqlalchemy import Column, Integer, Unicode, ForeignKey
from sqlalchemy.ext.associationproxy import association_proxy
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship, backref

Base = declarative_base()

class Article(Base):
__tablename__ = 'article'
id = Column(Integer, primary_key=True)
tags = association_proxy('articletags', 'tag')

class ArticleTag(Base):
__tablename__ = 'articletag'
article_id = Column(Integer, ForeignKey('article.id'),
primary_key=True)
article = relationship(Article, backref=backref('articletags'))
tag_id = Column(Integer, ForeignKey('tag.id'), primary_key=True)
tag = relationship('Tag')

class Tag(Base):
__tablename__ = 'tag'
id = Column(Integer, primary_key=True)

Resource objects of type ``'article'`` will have ``tags`` relationship that
proxies directly to the ``Tag`` resource through the ``ArticleTag`` table:

.. sourcecode:: json

{
"data": {
"id": "1",
"type": "article",
"relationships": {
"tags": {
"data": [
{
"id": "1",
"type": "tag"
},
{
"id": "2",
"type": "tag"
}
],
}
}
}
}

By default, the intermediate ``articletags`` relationship does not appear as a
relationship in the resource object.


Polymorphic models
------------------

Flask-Restless automatically handles polymorphic models. For single-table
inheritance, we have made some design choices we believe are reasonable.
Requests to create, update, or delete a resource must specify a ``type`` that
matches the collection name of the endpoint. This means you cannot request to
create a resource of the subclass type at the endpoint for the superclass type,
for example. On the other hand, requests to fetch a collection of objects that
have a subclass will yield a response that includes all resources of the
superclass and all resources of any subclass.

For example, consider a setup where there are employees and some employees are
managers::

from sqlalchemy import Column, Integer, Enum
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()

class Employee(Base):
__tablename__ = 'employee'
id = Column(Integer, primary_key=True)
type = Column(Enum('employee', 'manager'), nullable=False)
__mapper_args__ = {
'polymorphic_on': type,
'polymorphic_identity': 'employee'
}

class Manager(Employee):
__mapper_args__ = {
'polymorphic_identity': 'manager'
}

Collection name
...............

When creating an API for these models, Flask-Restless chooses the polymorphic
identity as the collection name::

>>> from flask.ext.restless import collection_name
>>>
>>> manager.create_api(Employee)
>>> manager.create_api(Manager)
>>> collection_name(Employee)
'employee'
>>> collection_name(Manager)
'manager'

Creating and updating resources
...............................

Creating a resource require the ``type`` element of the resource object in the
request to match the collection name of the endpoint::

>>> from flask import json
>>> import requests
>>>
>>> headers = {
... 'Accept': 'application/vnd.api+json',
... 'Content-Type': 'application/vnd.api+json'
... }
>>> resource = {'data': {'type': 'employee'}}
>>> data = json.dumps(resource)
>>> response = requests.post('https://example.com/api/employee', data=data,
... headers=headers)
>>> response.status_code
201
>>> resource = {'data': {'type': 'manager'}}
>>> data = json.dumps(resource)
>>> response = requests.post('https://example.com/api/manager', data=data,
... headers=headers)
>>> response.status_code
201

If the ``type`` does not match the collection name for the endpoint, the server
responds with a :http:statuscode:`409`::

>>> resource = {'data': {'type': 'manager'}}
>>> data = json.dumps(resource)
>>> response = requests.post('https://example.com/api/employee', data=data,
... headers=headers)
>>> response.status_code
409

The same rules apply for updating resources.

Fetching resources
..................

Assume the database contains an employee with ID 1 and a manager with ID 2.
You can only fetch each individual resource at the endpoint for the exact type
of that resource::

>>> response = requests.get('https://example.com/api/employee/1')
>>> response.status_code
200
>>> response = requests.get('https://example.com/api/manager/2')
>>> response.status_code
200

You cannot access individual resources of the subclass at the endpoint for the
superclass::

>>> response = requests.get('https://example.com/api/employee/2')
>>> response.status_code
404
>>> response = requests.get('https://example.com/api/manager/1')
>>> response.status_code
404

Fetching from the superclass endpoint yields a response that includes resources
of the superclass and resources of the subclass::

>>> response = requests.get('https://example.com/api/employee')
>>> document = json.loads(response.data)
>>> resources = document['data']
>>> employee, manager = resources
>>> employee['type']
'employee'
>>> employee['id']
'1'
>>> manager['type']
'manager'
>>> manager['id']
'2'

Deleting resources
..................

Assume the database contains an employee with ID 1 and a manager with ID 2.
You can only delete from the endpoint that matches the exact type of the
resource::

>>> response = requests.delete('https://example.com/api/employee/2')
>>> response.status_code
404
>>> response = requests.delete('https://example.com/api/manager/1')
>>> response.status_code
404
>>> response = requests.delete('https://example.com/api/employee/1')
>>> response.status_code
204
>>> response = requests.delete('https://example.com/api/manager/2')
>>> response.status_code
204
1 change: 1 addition & 0 deletions docs/index.rst
Expand Up @@ -38,6 +38,7 @@ the JSON API specification.
basicusage
requestformat
customizing
databasesetup

API reference
-------------
Expand Down
11 changes: 10 additions & 1 deletion flask_restless/manager.py
Expand Up @@ -20,6 +20,7 @@
from uuid import uuid1
import sys

from sqlalchemy.inspection import inspect
from flask import Blueprint
from flask import url_for as flask_url_for

Expand Down Expand Up @@ -601,7 +602,15 @@ def create_api_blueprint(self, name, model, methods=READONLY_METHODS,
msg = 'Collection name must be nonempty'
raise IllegalArgumentError(msg)
if collection_name is None:
collection_name = model.__table__.name
# If the model is polymorphic in a single table inheritance
# scenario, this should *not* be the tablename, but perhaps
# the polymorphic identity?
mapper = inspect(model)
if mapper.polymorphic_identity is not None:
collection_name = mapper.polymorphic_identity
else:
collection_name = model.__table__.name

# convert all method names to upper case
methods = frozenset((m.upper() for m in methods))
# the name of the API, for use in creating the view and the blueprint
Expand Down
1 change: 0 additions & 1 deletion flask_restless/serialization/__init__.py
Expand Up @@ -16,7 +16,6 @@
from .exceptions import SerializationException
from .serializers import DefaultSerializer
from .serializers import JsonApiDocument
from .serializers import simple_heterogeneous_serialize_many
from .serializers import simple_serialize
from .serializers import simple_serialize_many
from .serializers import simple_relationship_serialize
Expand Down
25 changes: 19 additions & 6 deletions flask_restless/serialization/deserializers.py
Expand Up @@ -36,6 +36,7 @@
from ..helpers import get_by
from ..helpers import has_field
from ..helpers import is_like_list
from ..helpers import model_for
from ..helpers import strings_to_datetimes


Expand Down Expand Up @@ -129,31 +130,43 @@ def _load(self, data):
raise MissingType
if 'id' in data and not self.allow_client_generated_ids:
raise ClientGeneratedIDNotAllowed
# Determine the model from the type name that the the user is
# requesting. If no model is known with the given type, raise an
# exception.
type_ = data.pop('type')
expected_type = collection_name(self.model)
try:
model = model_for(type_)
except ValueError:
raise ConflictingType(expected_type, type_)
# If we wanted to allow deserializing a subclass of the model,
# we could use:
#
# if not issubclass(model, self.model) and type != expected_type:
#
if type_ != expected_type:
raise ConflictingType(expected_type, type_)
# Check for any request parameter naming a column which does not exist
# on the current model.
for field in data:
if field == 'relationships':
for relation in data['relationships']:
if not has_field(self.model, relation):
if not has_field(model, relation):
raise UnknownRelationship(relation)
elif field == 'attributes':
for attribute in data['attributes']:
if not has_field(self.model, attribute):
if not has_field(model, attribute):
raise UnknownAttribute(attribute)
# Determine which related instances need to be added.
links = {}
if 'relationships' in data:
links = data.pop('relationships', {})
for link_name, link_object in links.items():
related_model = get_related_model(self.model, link_name)
related_model = get_related_model(model, link_name)
DRD = DefaultRelationshipDeserializer
deserializer = DRD(self.session, related_model, link_name)
# Create the deserializer for this relationship object.
if is_like_list(self.model, link_name):
if is_like_list(model, link_name):
deserialize = deserializer.deserialize_many
else:
deserialize = deserializer.deserialize
Expand All @@ -168,9 +181,9 @@ def _load(self, data):
data.update(data.pop('attributes', {}))
# Special case: if there are any dates, convert the string form of the
# date into an instance of the Python ``datetime`` object.
data = strings_to_datetimes(self.model, data)
data = strings_to_datetimes(model, data)
# Create the new instance by keyword attributes.
instance = self.model(**data)
instance = model(**data)
# Set each relation specified in the links.
for relation_name, related_value in links.items():
setattr(instance, relation_name, related_value)
Expand Down

0 comments on commit f91a6b9

Please sign in to comment.