第 12 章 多对多关系的 ORM 实现
从一个生活场景说起
一对多是"一个妈妈有多个孩子",那多对多是什么?想想大学选课:
- 一个学生可以选多门课;
- 一门课可以有多个学生。
这次外键该放哪?放学生表里?不行,一门课的"所属学生"有一堆;放课程表里?同理不行。两边都装不下对方——就像一张课表写不下所有选课学生的名字。
解决办法是引入一个中间人:教务处的一张选课登记表。它不记录学生信息、也不记录课程信息,只记一对一对的"几号学生选了几号课":
| student_id | course_id |
|---|---|
| 1 | 101 |
| 1 | 102 |
| 2 | 101 |
查"小明选了哪些课":在登记表里找小明的所有行,再按课程号去课程表取详情。这张登记表就是中间表(关联表)。
核心概念
中间表的特征
- 只有两列外键:分别指向两端的表;
- 两列合起来做联合主键:保证"同一个学生选同一门课"只出现一次;
- 不需要对应的 Python 类——它是幕后工作者,ORM 通过它跳转,但你日常操作看不到它。
secondary 参数
普通的 relationship 靠外键直连两张表;多对多中间隔着一张表,需要用 secondary=enrollment 告诉 SQLAlchemy:"经由这张中间表去找到对方"。
模型定义(逐行讲解)
from sqlalchemy import create_engine, Table, Column, ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
class Base(DeclarativeBase): ...
# ① 中间表:纯外键表,不需要对应的 Python 类
enrollment = Table(
"enrollment",
Base.metadata,
Column("student_id", ForeignKey("student.id"), primary_key=True),
Column("course_id", ForeignKey("course.id"), primary_key=True),
)
class Student(Base):
__tablename__ = "student"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50))
# ② 通过 secondary 声明"经由 enrollment 表找到课程"
courses: Mapped[list["Course"]] = relationship(
secondary=enrollment, back_populates="students"
)
class Course(Base):
__tablename__ = "course"
id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(100))
students: Mapped[list[Student]] = relationship(
secondary=enrollment, back_populates="courses"
)
engine = create_engine(
"mysql+pymysql://root:123456@localhost:3306/demo_db?charset=utf8mb4",
echo=True,
)
Base.metadata.create_all(engine)与一对多的差别只有两点:
- 多了一张
Table形式的中间表(注意它用的是第 2 章 Core 风格的Table,不是映射类); relationship里多了secondary=enrollment。
使用示例
from sqlalchemy.orm import Session
with Session(engine) as session:
s1 = Student(name="小明")
c1 = Course(title="数学")
c2 = Course(title="英语")
s1.courses.extend([c1, c2]) # 建立关联:像操作普通列表一样
session.add(s1)
session.commit()
# commit 时 SQLAlchemy 自动做了三件事:
# INSERT 学生、INSERT 两门课程、INSERT 两行到 enrollment 中间表
print([c.title for c in s1.courses]) # ['数学', '英语']
print([s.name for s in c1.students]) # ['小明']
# 解除关联:只删中间表的行,不会删课程本身
s1.courses.remove(c2)
session.commit()体会一下:你从头到尾没碰过 enrollment 表,但 commit 后打开数据库看,中间表里已经躺着两条记录了。这就是 ORM 的魅力——你只描述"小明选了数学课",SQL 细节全自动。
关键知识点
| 概念 | 说明 |
|---|---|
| 中间表 | 存两个外键,联合主键 |
secondary=中间表 | 告诉 relationship 经由哪张表关联 |
| 双向访问 | 两边都能通过集合属性访问对方 |
深入理解:为什么用联合主键?
student_id 和 course_id 各自都不是唯一的(一个学生选多门课,一门课有多个学生),但它们的组合必须唯一——同一个人不能重复选同一门课。把两列一起设为主键(联合主键),MySQL 就会替你拦截重复插入,这是最底层、最可靠的防重复手段。
进阶内容
1. 中间表要存额外信息怎么办? 比如想记录"选课时间""期末成绩"——这些信息属于"选课"这个行为本身,而不是属于学生或课程。此时 Table + secondary 就不够用了,要把中间表升级成完整的模型类,称为关联对象模式(Association Object):
class Enrollment(Base): # 中间表升级为模型
__tablename__ = "enrollment"
student_id: Mapped[int] = mapped_column(ForeignKey("student.id"), primary_key=True)
course_id: Mapped[int] = mapped_column(ForeignKey("course.id"), primary_key=True)
score: Mapped[float | None] # 额外字段:成绩
student: Mapped["Student"] = relationship(back_populates="enrollments")
course: Mapped["Course"] = relationship(back_populates="enrollments")
class Student(Base):
__tablename__ = "student"
id: Mapped[int] = mapped_column(primary_key=True)
enrollments: Mapped[list[Enrollment]] = relationship(back_populates="student")本质上是把多对多拆成了两个一对多。经验法则:中间表一旦需要第三个字段,就该升级成关联对象。
2. 删除时的边界。 student.courses.remove(course) 只删中间表的关联行,课程和学生都还在。想连课程一起删?那需要显式 session.delete(course)——多对多关系中,移除关联 ≠ 删除对象,分清楚这两个动作。
3. 性能提示。 MySQL InnoDB 会为中间表的两个外键自动建索引,所以"查某学生的课程"和"查某课程的学生"两个方向都很快,多对多查询性能有保障。
注意事项
- 如果中间表需要额外字段(如"选课时间""成绩"),就不能用
Table + secondary,要把中间表也做成完整的模型类(称为"关联对象模式")。 - 中间表的两个外键应设为联合主键,防止重复关联。
- 删除多对多关联用
student.courses.remove(course),只会删中间表的行,不会删课程本身。 - MySQL InnoDB 会为中间表的两个外键自动建索引,多对多查询性能有保障。
- 中间表没有自己的映射类,所以无法对它做懒加载之外的精细控制;需要控制就用关联对象模式。
本章小结
- 多对多 = 两端各一个
relationship+ 一张secondary指定的中间表; - 中间表只存两个外键、联合主键,无需 Python 类;
- 操作关联就像操作列表:
append/extend/remove,SQL 全自动; - 中间表要存额外字段时,升级为"关联对象模式"(拆成两个一对多)。