SQLAlchemy ORM事件挂钩属性保持不变

Dev*_*evy 3 python orm events sqlalchemy

我正在努力在SQLAlchemy事件中找到一种方法,在属性更新并持久保存到数据库中时调用外部API.这是我的背景:

User具有名为的属性的模型birthday.当User模型实例得到更新和保存时,我想调用外部API来相应地更新该用户的生日.

我已经尝试了属性事件,但是,它会生成太多命中,并且无法保证set/ removeattribute事件最终会被持久化(自动提交设置为False,并且在发生错误时事务会回滚.)

会话事件无法工作,因为它需要一个Session/SessionFactory作为参数,并且代码中只有很多地方可以使用会话.

我一直在官方文档中查看所有可能的SQLAlchemy ORM事件挂钩,但我找不到其中任何一个满足我的要求.

我想知道是否有其他人对如何在SQLAlchemy中实现这种组合事件触发器有任何了解.谢谢.

uni*_*rio 11

您可以通过组合多个事件来完成此操作.您需要使用的具体事件取决于您的特定应用程序,但基本思路是这样的:

  1. [ InstanceEvents.load]加载实例时,记下它已加载但以后没有添加到会话的事实(我们只想保存实例加载时的初始状态)
  2. [ AttributeEvents.set/append/remove]当属性发生变化时,请记下它已被更改的事实,并在必要时更改它的内容(如果您不需要初始状态,前两个步骤是可选的)
  3. [ SessionEvents.before_flush]发生刷新时,记下实际保存的实例
  4. [ SessionEvents.before_commit]在提交完成之前,记下实例的当前状态(因为在提交后你可能无法访问它)
  5. SessionEvents.after_commit提交完成后,[ ]关闭自定义事件处理程序并清除您保存的实例

一个有趣的挑战是事件的顺序.如果您在session.commit()没有执行操作的情况下执行操作session.flush(),您会注意到before_commit事件在事件发生之前触发before_flush,这与您session.flush()之前执行操作的情况不同session.commit().解决的办法是打电话给session.flush()你的before_commit电话给力的排序.这可能不是100%犹太人,但它在我的生产中适用.

这是事件排序的(简单)图表:

begin
load
(save initial state)
set attribute
...
flush
set attribute
...
flush
...
(save modified state)
commit
(fire off "object saved and changed" event)
Run Code Online (Sandbox Code Playgroud)

完整的例子

from itertools import chain
from weakref import WeakKeyDictionary, WeakSet
from sqlalchemy import Column, String, Integer, create_engine
from sqlalchemy import event
from sqlalchemy.orm import sessionmaker, object_session
from sqlalchemy.ext.declarative import declarative_base

Base = declarative_base()

engine = create_engine("sqlite://")
Session = sessionmaker(bind=engine)


class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True)
    birthday = Column(String)


@event.listens_for(User.birthday, "set", active_history=True)
def _record_initial_state(target, value, old, initiator):
    session = object_session(target)
    if session is None:
        return
    if target not in session.info.get("loaded_instances", set()):
        return
    initial_state = session.info.setdefault("initial_state", WeakKeyDictionary())
    # this is where you save the entire object's state, not necessarily just the birthday attribute
    initial_state.setdefault(target, old)


@event.listens_for(User, "load")
def _record_loaded_instances_on_load(target, context):
    session = object_session(target)
    loaded_instances = session.info.setdefault("loaded_instances", WeakSet())
    loaded_instances.add(target)


@event.listens_for(Session, "before_flush")
def track_instances_before_flush(session, context, instances):
    modified_instances = session.info.setdefault("modified_instances", WeakSet())
    for obj in chain(session.new, session.dirty):
        if session.is_modified(obj) and isinstance(obj, User):
            modified_instances.add(obj)


@event.listens_for(Session, "before_commit")
def set_pending_changes_before_commit(session):
    session.flush()  # IMPORTANT
    initial_state = session.info.get("initial_state", {})
    modified_instances = session.info.get("modified_instances", set())
    del session.info["modified_instances"]
    pending_changes = session.info["pending_changes"] = []
    for obj in modified_instances:
        initial = initial_state.get(obj)
        current = obj.birthday
        pending_changes.append({
            "initial": initial,
            "current": current,
        })
        initial_state[obj] = current


@event.listens_for(Session, "after_commit")
def after_commit(session):
    pending_changes = session.info.get("pending_changes", {})
    del session.info["pending_changes"]
    for changes in pending_changes:
        print(changes)  # this is where you would fire your custom event

    loaded_instances = session.info["loaded_instances"] = WeakSet()
    for v in session.identity_map.values():
        if isinstance(v, User):
            loaded_instances.add(v)


def main():
    engine = create_engine("sqlite://", echo=False)
    Base.metadata.create_all(bind=engine)
    session = Session(bind=engine)

    user = User(birthday="foo")
    session.add(user)
    user.birthday = "bar"
    session.flush()
    user.birthday = "baz"
    session.commit()  # prints: {"initial": None, "current": "baz"}
    user.birthday = "foobar"
    session.commit()  # prints: {"initial": "baz", "current": "foobar"}

    session.close()


if __name__ == "__main__":
    main()
Run Code Online (Sandbox Code Playgroud)

如您所见,它有点复杂,不符合人体工程学.如果它被整合到ORM中会更好,但我也理解可能有理由不这样做.

  • @Devy 在您的特定情况下,您可以附加到所有 `db.s_*` 实例,因为它们是 `scoped_session`s;这将影响由`db.s_*` 代理创建的所有会话。或者,您可以附加到全局`sqlalchemy.orm.session.Session` 类,这将影响*所有* 会话。 (3认同)