Clone wiki

sqlalchemy / UsageRecipes / ExpireRelationshipOnFKChange

ExpireRelationshipOnFKChange

The FAQ has for many years talked about how SQLAlchemy regards changes on foreign key attributes. When an operation like setting "foo_id" on an object "Foo" occurs, nothing happens to the associated relationships. The ORM is designed to work the other way; it will keep everything in sync as long as you deal with object relationships. If you want to modify column attributes directly, that's fine, but there's no "reverse" synchronization of this back to the relevant relationships.

Ticket #1939 also talks about the possibility of adding this feature; but it has caveats and uncertainties that still have us hesitant to add this feature. One primary issue is that SQLAlchemy, unlike many other ORMs, actually caches collections, rather than having them emit SQL every time. Which means to expire these collections on an attribute set operation means we have to actively go out and emit new SQL to find the new parent object, unless the relationship is a so-called "use_get many to one". The implications of these kinds of issues, combined with the fact that the SQLAlchemy ORM has a recommended pattern that avoids this issue entirely, keeps us hesitant to make this a supported feature.

However, SQLAlchemy has a comprehensive event API today, so we can produce a recipe version of this feature for simple cases, including that it can be customized to emit more or less SQL as needed. A full example is below.

import weakref

from sqlalchemy.orm import object_session
from sqlalchemy.orm import Session
from sqlalchemy import event
from sqlalchemy import inspect

from sqlalchemy.orm import interfaces
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()


def expire_for_fk_change(target, fk_value, relationship_prop, column_attr):
    """Expire relationship attributes when a many-to-one column changes."""

    sess = object_session(target)

    if sess is not None:
        # optional behavior #1 - expire the "User.articles"
        # collection on the existing "user" object
        if relationship_prop.back_populates and \
                relationship_prop.key in target.__dict__:
            obj = getattr(target, relationship_prop.key)
            if inspect(obj).persistent:
                sess.expire(obj, [relationship_prop.back_populates])

        # behavior #2 - expire Article.user
        if inspect(target).persistent:
            sess.expire(target, [relationship_prop.key])

        # optional behavior #3 - "trick" the ORM by actually
        # setting the value ahead of time, then emitting a load
        # for the attribute so that the *new* Article.user
        # is loaded.  Then, expire User.articles on *that*.
        # Other techniques here including looking in the identity
        # map for "value", if this is a simple many-to-one get.
        if relationship_prop.back_populates:
            target.__dict__[column_attr] = fk_value
            new = getattr(target, relationship_prop.key)
            if new is not None:
                sess.expire(new, [relationship_prop.back_populates])
    else:
        # no Session yet, do it later
        if target not in _emit_on_pending:
            _emit_on_pending[target] = l = []
        else:
            l = _emit_on_pending[target]
        l.append((fk_value, relationship_prop, column_attr))


_emit_on_pending = weakref.WeakKeyDictionary()


@event.listens_for(Session, "pending_to_persistent")
def _pending_callables(session, obj):
    """Expire relationships when a new object w/ a foreign key becomes persistent"""
    arg_set = _emit_on_pending.pop(obj, ())
    for args in arg_set:
        expire_for_fk_change(obj, *args)


@event.listens_for(Session, "persistent_to_deleted")
def _persistent_to_deleted(session, obj):
    """Expire relationships when an object w/ a foreign key becomes deleted"""
    mapper = inspect(obj).mapper
    for prop in mapper.relationships:
        if prop.direction is interfaces.MANYTOONE:
            for col in prop.local_columns:
                colkey = mapper.get_property_by_column(col).key
                expire_for_fk_change(obj, None, prop, colkey)


@event.listens_for(Base, "attribute_instrument", propagate=True)
def _listen_for_changes(cls, key, inst):
    mapper = inspect(cls)
    if key not in mapper.relationships:
        return
    prop = inst.property

    if prop.direction is interfaces.MANYTOONE:
        for col in prop.local_columns:
            colkey = mapper.get_property_by_column(col).key
            _expire_prop_on_col(cls, prop, colkey)
    elif prop.direction is interfaces.ONETOMANY:
        remote_mapper = prop.mapper
        # the collection *has* to have a MANYTOONE backref so we
        # can look up the parent.  so here we make one if it doesn't
        # have it already, as is the case in this example
        if not prop.back_populates:
            name = "_%s_backref" % prop.key
            backref_prop = relationship(
                prop.parent, back_populates=prop.key)

            remote_mapper.add_property(name, backref_prop)
            prop.back_populates = name


def _expire_prop_on_col(cls, prop, colkey):
    @event.listens_for(getattr(cls, colkey), "set")
    def expire(target, value, oldvalue, initiator):
       """Expire relationships when the foreign key attribute on an object changes"""
       expire_for_fk_change(target, value, prop, colkey)

if __name__ == '__main__':
    from sqlalchemy.orm import Session, relationship
    from sqlalchemy import create_engine, Column, Integer, ForeignKey

    class User(Base):
        __tablename__ = 'user'
        id = Column(Integer, primary_key=True)

    class Article(Base):
        __tablename__ = 'article'
        id = Column(Integer, primary_key=True)
        content = Column(Integer)

        user_id = Column(Integer, ForeignKey(User.id))
        user = relationship("User", backref="articles")

    e = create_engine("sqlite://", echo=True)

    Base.metadata.create_all(e)

    DBSession = Session(e)

    user1 = User(id=23)
    user2 = User(id=42)
    DBSession.add(user2)
    DBSession.add(user1)

    article = Article()
    article.user_id = user1.id
    DBSession.add(article)

    DBSession.flush()
    assert article.user.id == 23
    assert article.user_id == 23

    article.user_id = 42

    DBSession.flush()
    assert article.user.id == 42
    assert article.user_id == 42

    DBSession.commit()
    assert article.user.id == 42
    assert article.user_id == 42

    assert article in user2.articles
    assert article not in user1.articles

    article.user_id = 23

    assert article not in user2.articles
    assert article in user1.articles

    article2 = Article(user_id=42)
    assert article2 not in user2.articles

    DBSession.add(article2)
    DBSession.flush()

    assert article2 in user2.articles

Updated