sqlalchemy 如何写“软删除”并在查询中自动过滤已删记录

软删除是逻辑标记而非物理移除,仅用 is_deleted 字段不够,需全局拦截查询、关联加载及事件监听以确保所有数据入口均尊重该标记。

什么是软删除,为什么不能只靠 is_deleted 字段

软删除本质是逻辑标记而非物理移除,但光加个 is_deleted 布尔字段远远不够。SQLAlchemy 不会自动识别这个字段的语义,所有查询(包括 session.query(Model)

Model.query.all()、关联加载)默认都会拉出已标记删除的记录。必须显式干预查询构建过程,否则“软”就变成了“假装删了”。

常见错误现象:model.delete() 后查出来还在;外键关联对象仍能加载已软删的父记录;admin 后台列表里出现“已删除”的脏数据。

用 query_class + 自定义 Query 实现全局过滤

最直接可控的方式是重写模型的默认查询类,让每次 Model.querysession.query(Model) 都自动加上 is_deleted == False 条件。

实操建议:

  • 定义一个继承 Query 的子类,在 __iter__all() 调用前统一 apply filter
  • 在模型中通过 query_class 属性指定该类
  • 确保 is_deleted 字段类型为 Boolean,且有默认值 False
class SoftDeleteQuery(Query):
    def __iter__(self):
        return super().filter(self._mapper_zero().is_deleted == False).__iter__()

class User(Base): tablename = 'users' id = Column(Integer, primary_key=True) name = Column(String(50)) is_deleted = Column(Boolean, default=False) query_class = SoftDeleteQuery

注意:这种方式对 session.query(User) 无效(它不走 User.query),需配合 session.query(User).with_polymorphic('*') 或改用 User.query 风格。

用 SQLAlchemy 2.0+ 的 select() + with_only_columns 替代旧式 query

SQLAlchemy 2.0 推荐使用 select() 构建查询,此时无法复用 query_class。需手动封装一个带过滤的查询构造器,或借助 Event 拦截 select()

更稳妥的做法是定义一个类方法:

class User(Base):
    # ... 字段定义同上
    @classmethod
    def not_deleted(cls):
        return select(cls).where(cls.is_deleted == False)

使用:

stmt = User.not_deleted().where(User.name.contains('john')) result = session.execute(stmt).scalars().all()

这样既清晰又可控,避免隐式行为。如果项目已大量使用 select(),硬塞 query_class 反而增加理解成本。

外键关联和 joinedload 时如何保持软删除语义

软删除最大的坑不在主表,而在关联——比如查订单时,joinedload(Order.user) 会把已软删的用户也一起加载进来,破坏业务一致性。

解决方案分两层:

  • 对一对一/多对一外键,在关系定义中加 primaryjoin 条件,例如:user = relationship('User', primaryjoin='and_(Order.user_id == User.id, User.is_deleted == False)')
  • 对一对多(如 User.orders),在 relationship 中加 viewonly=True + 单独定义一个 not_deleted_orders 属性,用 select() + where 显式过滤
  • 避免在 lazy='joined' 场景下依赖默认关联,容易漏掉 is_deleted 判断

真正难处理的是嵌套三层以上的关联(比如 Order → Item → Supplier),这时候推荐用 CTE 或物化视图预过滤,而不是靠 ORM 动态拼条件——性能和可读性都会迅速恶化。

软删除不是加个字段就完事,关键在于所有数据入口(CRUD、关联、批量操作、raw SQL)是否都尊重这个标记。最容易被忽略的是事件监听器(如 @event.listens_for(User, 'before_insert'))和 Alembic 迁移脚本里的初始数据插入——它们常常绕过模型层逻辑,直接写入 is_deleted = True 却没配好默认值或索引。