第 11 章 一对多关系的 ORM 实现
从一个生活场景说起
回忆第 7 章"查户口"的痛苦:想知道张三写了哪些文章,得手动写 JOIN。有了 ORM,这件事变成什么样?
就像智能手机的通讯录:点开"张三"这个联系人,往下一滑就是他名下的所有通话记录;反过来,点开任意一条通话记录,也能一键跳回"张三"这个联系人。你完全不用关心背后是怎么查的——关联已经长在对象身上了。
relationship 就是给映射类装上这种"通讯录跳转"能力的魔法。
核心概念
两个角色分工明确
ForeignKey(外键):数据库层面的关联,是真实存在于表里的列和约束(第 6 章);relationship(关系):Python 层面的关联,只是对象上的"便捷通道",不产生任何新列。
类比:外键是户口本上写的"户主编号"(官方档案),relationship 是手机通讯录里的"快捷拨号"(方便你按一下就找到人)。前者管数据的正确性,后者管使用的便利性。
双向绑定:back_populates
我们希望两边都能跳转:
user.articles→ 这个用户的所有文章(列表,"多"的一方);article.author→ 这篇文章的作者(单个对象,"一"的一方)。
back_populates 就是把这两个属性绑成一对的参数——你在这边操作,那边自动同步。
模型定义(逐行讲解)
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)三处关键点:
Mapped[list["Article"]]:类型标注告诉 Python"这是个列表";类名用字符串"Article"是因为此时 Article 类还没定义(它在下面),用字符串做"前向引用";author_id+ForeignKey:这是真实的数据库列,JOIN 时就靠它;back_populates="author"与back_populates="articles":两边互相填对方的属性名,形成闭环。
使用示例
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 还活着。下面这段代码会报错:
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. 级联删除:两种路径,二选一。
# 路径一: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。