-
-
Notifications
You must be signed in to change notification settings - Fork 1.5k
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