Skip to content

Commit

Permalink
Merge 9fbf37e into d3ef68a
Browse files Browse the repository at this point in the history
  • Loading branch information
jfinkels authored Dec 26, 2016
2 parents d3ef68a + 9fbf37e commit b125994
Show file tree
Hide file tree
Showing 9 changed files with 801 additions and 492 deletions.
105 changes: 95 additions & 10 deletions docs/databasesetup.rst
Original file line number Diff line number Diff line change
Expand Up @@ -7,42 +7,58 @@ Flask-Restless automatically handles SQLAlchemy models defined with
.. _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.
element of a resource in the JSON document and hides the association table or
association object.


Proxying association objects
............................

For example, consider a setup where there are articles and tags in a
many-to-many relationship::
TODO Add link to the correct section of the SQLAlchemy documentation here.

When proxying a to-many relationship via an association object, the related
resources will appear in the ``relationships`` element of the resource object
but the association object will not appear. For example, in the following
setup, each article has a to-many relationship to tags via the ``ArticleTag``
object::

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
from sqlalchemy.orm import relationship

Base = declarative_base()

class Article(Base):
__tablename__ = 'article'
id = Column(Integer, primary_key=True)
tags = association_proxy('articletags', 'tag')
articletags = relationship('ArticleTag',
cascade='all, delete-orphan')
tags = association_proxy('articletags', 'tag',
creator=lambda tag: ArticleTag(tag=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)
name = Column(Unicode)

Resource objects of type ``'article'`` will have ``tags`` relationship that
proxies directly to the ``Tag`` resource through the ``ArticleTag`` table:
Resource objects of type ``'article'`` will have a ``tags`` relationship that
proxies directly to the ``Tag`` resource through the ``ArticleTag`` table. The
intermediate ``articletags`` relationship does not appear as a relationship in
the resource object:

.. sourcecode:: json

Expand All @@ -67,8 +83,77 @@ proxies directly to the ``Tag`` resource through the ``ArticleTag`` table:
}
}

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

Proxying association tables
...........................

TODO Add link to the correct section of the SQLAlchemy documentation here.

When proxying an attribute of a to-many relationship via an association table,
the attribute will appear in the ``attributes`` element of the resource object
and the to-many relationship will appear in the ``relationships`` element of
the resource object but the association table will not appear. For example, in
the following setup, each article has an association proxy ``tag_names`` which
is a list of the ``name`` attribute of each related tag::

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

Base = declarative_base()

class Article(Base):
__tablename__ = 'article'
id = Column(Integer, primary_key=True)
tags = relationship('Tag', secondary=lambda: articletags_table)
tag_names = association_proxy('tags', 'name',
creator=lambda s: Tag(name=s))

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

articletags_table = \
Table('articletags', Base.metadata,
Column('article_id', Integer, ForeignKey('article.id'),
primary_key=True),
Column('tag_id', Integer, ForeignKey('tag.id'),
primary_key=True))

Resource objects of type ``'article'`` will have a ``tag_names`` attribute that
is a list of tag names in addition to a ``tags`` relationship. The intermediate
``articletags`` table does not appear as a relationship in the resource object:

.. sourcecode:: json

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


Polymorphic models
Expand Down
8 changes: 1 addition & 7 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,7 @@ This is the documentation for version |version|. See also the the most recent

.. warning::

This is a "beta" version, so there may be more bugs than usual. There is one
fairly serious known issue with this version. Updating relationships via
`association proxies`_ is not working correctly. We cannot support
many-to-many relationships until this is resolved. If you have any insight
on how to fix this, please comment on GitHub issue :issue:`480`.

.. _association proxies: https://docs.sqlalchemy.org/en/latest/orm/extensions/associationproxy.html
This is a "beta" version, so there may be more bugs than usual.

.. _stable version documentation: https://flask-restless.readthedocs.org/en/stable/
.. _development version documentation: https://flask-restless.readthedocs.org/en/latest
Expand Down
199 changes: 162 additions & 37 deletions flask_restless/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from sqlalchemy import Time
from sqlalchemy.exc import NoInspectionAvailable
from sqlalchemy.ext.associationproxy import AssociationProxy
from sqlalchemy.orm import RelationshipProperty as RelProperty
from sqlalchemy.orm import RelationshipProperty
from sqlalchemy.sql import func
from sqlalchemy.sql.expression import ColumnElement
from sqlalchemy.inspection import inspect as sqlalchemy_inspect
Expand Down Expand Up @@ -65,53 +65,178 @@ def session_query(session, model):
return session.query(model)


# TODO Combine this function with the one below.
def scalar_collection_proxied_relations(model):
"""Yields the name of each relationship proxied to a scalar collection.
This includes each relationship to an association table for which
there is an association proxy that presents a scalar collection (for
example, a list of strings).
.. seealso::
:func:`assoc_proxy_scalar_collections`
Yields the names of association proxies for the relationships
found by this function.
.. versionadded:: 1.0.0
"""
mapper = sqlalchemy_inspect(model)
for k, v in mapper.all_orm_descriptors.items():
if isinstance(v, AssociationProxy):
# HACK SQLAlchemy only loads the association proxy
# on-demand. We need to call `hasattr` in order to force
# SQLAlchemy to load the attribute.
hasattr(model, k)
if not isinstance(v.remote_attr.property, RelationshipProperty):
if is_like_list(model, v.local_attr.key):
yield v.local_attr.key


def assoc_proxy_scalar_collections(model):
"""Yields the name of each association proxy collection as a string.
This includes each association proxy that proxies to a scalar
collection (for example, a list of strings) via an association
table. It excludes each association proxy that proxies to a
collection of instances (for example, a to-many relationship) via an
association object.
.. seealso::
:func:`scalar_collection_proxied_relations`
.. versionadded:: 1.0.0
"""
mapper = sqlalchemy_inspect(model)
for k, v in mapper.all_orm_descriptors.items():
if isinstance(v, AssociationProxy) \
and not isinstance(v.remote_attr.property, RelationshipProperty) \
and is_like_list(model, v.local_attr.key):
yield k


def get_relations(model):
"""Returns a list of relation names of `model` (as a list of strings).
"""Yields the name of each relationship of a model as a string.
For a relationship via an association proxy, this function shows
only the remote attribute, not the intermediate relationship. For
example, if there is a table for ``Article`` and ``Tag`` and a table
associating the two via a many-to-many relationship, ::
>>> from sqlalchemy.ext.declarative import declarative_base
>>>
>>> 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)
... name = Column(Unicode)
...
>>> get_relations(Article)
from sqlalchemy import Column
from sqlalchemy import ForeignKey
from sqlalchemy import Integer
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
Base = declarative_base()
class Article(Base):
__tablename__ = 'article'
id = Column(Integer, primary_key=True)
articletags = relationship('ArticleTag')
tags = association_proxy('articletags', 'tag',
creator=lambda tag: ArticleTag(tag=tag))
class ArticleTag(Base):
__tablename__ = 'articletag'
article_id = Column(Integer, ForeignKey('article.id'),
primary_key=True)
tag_id = Column(Integer, ForeignKey('tag.id'), primary_key=True)
tag = relationship('Tag')
class Tag(self.Base):
__tablename__ = 'tag'
id = Column(Integer, primary_key=True)
then this function reveals the ``tags`` proxy::
>>> list(get_relations(Article))
['tags']
On the other hand, this will *not* show association proxies that
proxy to a scalar collection via an association table. For example,
if there is an association proxy for a scalar collection like this::
from sqlalchemy import Column
from sqlalchemy import ForeignKey
from sqlalchemy import Integer
from sqlalchemy import Table
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
Base = declarative_base()
class Article(Base):
__tablename__ = 'article'
id = Column(Integer, primary_key=True)
tags = relationship('Tag', secondary=lambda: articletags_table)
tag_names = association_proxy('tags', 'name',
creator=lambda s: Tag(name=s))
class Tag(self.Base):
__tablename__ = 'tag'
id = Column(Integer, primary_key=True)
name = Column(Unicode)
articletags_table = \
Table('articletags', Base.metadata,
Column('article_id', Integer, ForeignKey('article.id'),
primary_key=True),
Column('tag_id', Integer, ForeignKey('tag.id'),
primary_key=True)
)
then this function yields only the ``tags`` relationship, not the
``tag_names`` attribute::
>>> list(get_relations(Article))
[]
"""
mapper = sqlalchemy_inspect(model)

# If we didn't have to deal with association proxies, we could just
# do `return list(mapper.relationships)`, but we want to replace all
# association attributes with the actual remote attributes, as the
# user would expect. Therefore, we get a dictionary mapping
# relationship name to association proxy local attribute name, then
# replace the key with the value wherever such a key appears in the
# list of relationships.
alldescriptors = mapper.all_orm_descriptors.items()
# TODO In Python 2.7+, this should be a dict comprehension.
association_proxies = dict((v.local_attr.key, k) for k, v in alldescriptors
if isinstance(v, AssociationProxy))
return [association_proxies.get(r, r) for r in mapper.relationships.keys()]
# do `return list(mapper.relationships)`.
#
# However, we need to deal with (at least) two different usages of
# association proxies: one in which the proxy is to a scalar
# collection (like a list of strings) and one in which the proxy is
# to a collection of instances (like a to-many relationship).
#
# First we record each association proxy and the the local attribute
# through which it proxies. This information is stored in a mapping
# from local attribute key to proxy name. For example, an
# association proxy defined like this::
#
# tags = associationproxy('articletags', 'tag')
#
# is stored below as a dictionary entry mapping 'articletags' to
# 'tags'.
association_proxies = {}
for k, v in mapper.all_orm_descriptors.items():
if isinstance(v, AssociationProxy):
association_proxies[v.local_attr.key] = k

# Next we determine which association proxies represent scalar
# collections as opposed to to-many relationships. We need to ignore
# these.
scalar_collections = set(assoc_proxy_scalar_collections(model))

# Finally we find all plain old relationships and all association
# proxy relationships.
#
# We exclude those assocation proxies that are for scalar
# collections.
for r in mapper.relationships.keys():
if r in association_proxies:
if association_proxies[r] not in scalar_collections:
yield association_proxies[r]
else:
yield r


def get_related_model(model, relationname):
Expand Down Expand Up @@ -238,7 +363,7 @@ def get_field_type(model, fieldname):
field = field.remote_attr
if hasattr(field, 'property'):
prop = field.property
if isinstance(prop, RelProperty):
if isinstance(prop, RelationshipProperty):
return None
return prop.columns[0].type
return None
Expand Down
Loading

0 comments on commit b125994

Please sign in to comment.