Skip to content

第 11 章 一对多关系的 ORM 实现 ​

从一个生活场景说起 ​

回忆第 7 章"查户口"的痛苦:想知道张三写了哪些文章,得手动写 JOIN。有了 ORM,这件事变成什么样?

就像智能手机的通讯录:点开"张三"这个联系人,往下一滑就是他名下的所有通话记录;反过来,点开任意一条通话记录,也能一键跳回"张三"这个联系人。你完全不用关心背后是怎么查的——关联已经长在对象身上了。

relationship 就是给映射类装上这种"通讯录跳转"能力的魔法。

核心概念 ​

两个角色分工明确 ​

  • ForeignKey(外键):数据库层面的关联,是真实存在于表里的列和约束(第 6 章);
  • relationship(关系):Python 层面的关联,只是对象上的"便捷通道",不产生任何新列。

类比:外键是户口本上写的"户主编号"(官方档案),relationship 是手机通讯录里的"快捷拨号"(方便你按一下就找到人)。前者管数据的正确性,后者管使用的便利性。

双向绑定:back_populates ​

我们希望两边都能跳转:

  • user.articles → 这个用户的所有文章(列表,"多"的一方);
  • article.author → 这篇文章的作者(单个对象,"一"的一方)。

back_populates 就是把这两个属性绑成一对的参数——你在这边操作,那边自动同步。

模型定义(逐行讲解) ​

python
from sqlalchemy import create_engine, ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship

class Base(DeclarativeBase): ...

class User(Base):
    __tablename__ = "user"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(50))

    # 一对多:一个用户有多篇文章
    # Mapped[list["Article"]] 表示这个属性是"一组 Article 对象"
    articles: Mapped[list["Article"]] = relationship(back_populates="author")

class Article(Base):
    __tablename__ = "article"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(200))
    author_id: Mapped[int] = mapped_column(ForeignKey("user.id"))   # 外键:数据库层面的关联

    # 多对一:每篇文章属于一个作者
    author: Mapped[User] = relationship(back_populates="articles")

engine = create_engine(
    "mysql+pymysql://root:123456@localhost:3306/demo_db?charset=utf8mb4",
    echo=True,
)
Base.metadata.create_all(engine)

三处关键点:

  1. Mapped[list["Article"]]:类型标注告诉 Python"这是个列表";类名用字符串 "Article" 是因为此时 Article 类还没定义(它在下面),用字符串做"前向引用";
  2. author_id + ForeignKey:这是真实的数据库列,JOIN 时就靠它;
  3. back_populates="author" 与 back_populates="articles":两边互相填对方的属性名,形成闭环。

使用示例 ​

python
from sqlalchemy.orm import Session

with Session(engine) as session:
    user = User(name="张三")
    user.articles = [Article(title="第一篇"), Article(title="第二篇")]
    session.add(user)          # 级联保存:只 add 了 user,文章会跟着一起插入!
    session.commit()

    # 访问关联
    print(user.articles)                  # 该用户的所有文章对象
    print(user.articles[0].author.name)   # 反过来访问作者:张三

注意这份"优雅":

  • 保存时:只 add(user),挂在它身上的两篇 Article 会被一并 INSERT(级联保存);
  • 读取时:user.articles 自动发 SQL 查出文章;article.author 自动查回作者;
  • 双向同步:user.articles.append(a) 之后,a.author 立即就是这个 user,无需手动设置。

关键知识点 ​

概念说明
relationship()定义对象级关联,不产生新列
back_populates双向绑定,两边互相可见
Mapped[list[X]]表示"多"的一方是集合
级联保存默认 save-update, merge,add 主对象时从对象一并保存

深入理解:懒加载与 DetachedInstanceError ​

user.articles 并不是查询用户时就一起取出来的,而是你第一次访问这个属性时,SQLAlchemy 才偷偷发一条 SELECT * FROM article WHERE author_id = 1。这叫懒加载(Lazy Loading)——用到才查,不用不查。

但懒加载有个前提:Session 还活着。下面这段代码会报错:

python
with Session(engine) as session:
    user = session.get(User, 1)
# with 结束,session 已关闭
print(user.articles)   # DetachedInstanceError!对象已与 Session"失联"

术语解释 —— DetachedInstanceError:对象脱离了 Session 的"托管"(detached),此时再触发懒加载,SQLAlchemy 没有可用的连接去查数据库,只能报错。

解决方法:要么在 Session 关闭前把需要的数据都取出来;要么用预加载(第 14 章的 selectinload)一次性查好。

进阶内容 ​

1. 级联删除:两种路径,二选一。

python
# 路径一:ORM 级联——删 user 时 SQLAlchemy 帮你删它的 articles
articles: Mapped[list["Article"]] = relationship(
    back_populates="author", cascade="all, delete-orphan"
)

# 路径二:数据库级联——让 MySQL 自己处理
author_id: Mapped[int] = mapped_column(ForeignKey("user.id", ondelete="CASCADE"))

不要叠加使用,否则两边抢着删,可能出现意外行为。一般推荐:简单项目用数据库级联(快),需要 ORM 事件钩子时用 ORM 级联。

2. delete-orphan 是什么? "孤儿删除":当一篇文章从 user.articles 列表中被移除(user.articles.remove(a)),它就成了"孤儿",commit 时会被 DELETE。不加这个选项,移除只是把 author_id 置为 NULL。

3. relationship 的 lazy 参数。 默认 lazy="select"(访问时才查)。还有 "joined"(主查询就 JOIN 出来)、"selectin"(用一条 IN 查询批量预加载)等。日常更推荐在查询时用 selectinload() 显式控制,见第 14 章。

注意事项 ​

  • relationship 只是 Python 层面的"便捷通道",数据库里真正的约束仍然是外键。
  • 访问 user.articles 会触发一次 SQL(懒加载),在 Session 关闭后访问会报 DetachedInstanceError。
  • 想删除用户时连文章一起删:relationship(cascade="all, delete-orphan", ...),慎用。
  • 数据库层面也可以加 ForeignKey("user.id", ondelete="CASCADE") 让 MySQL 自动级联删除,ORM 级联和数据库级联二选一即可,不要叠加。
  • 循环访问关联要小心 N+1 问题:for u in users: print(u.articles) 会对每个用户发一条 SQL,用户多时是性能灾难(第 14 章详解)。

本章小结 ​

  • 一对多 = "多"方放外键 + 两边各写一个 relationship 用 back_populates 绑定;
  • Mapped[list[X]] 是"多",Mapped[X] 是"一";
  • 级联保存是默认福利:add 主对象,关联对象一并入库;
  • 懒加载方便但有代价:Session 关闭后不能访问,循环访问会 N+1。