Skip to content

第 12 章 多对多关系的 ORM 实现 ​

从一个生活场景说起 ​

一对多是"一个妈妈有多个孩子",那多对多是什么?想想大学选课:

  • 一个学生可以选多门课;
  • 一门课可以有多个学生。

这次外键该放哪?放学生表里?不行,一门课的"所属学生"有一堆;放课程表里?同理不行。两边都装不下对方——就像一张课表写不下所有选课学生的名字。

解决办法是引入一个中间人:教务处的一张选课登记表。它不记录学生信息、也不记录课程信息,只记一对一对的"几号学生选了几号课":

student_idcourse_id
1101
1102
2101

查"小明选了哪些课":在登记表里找小明的所有行,再按课程号去课程表取详情。这张登记表就是中间表(关联表)。

核心概念 ​

中间表的特征 ​

  • 只有两列外键:分别指向两端的表;
  • 两列合起来做联合主键:保证"同一个学生选同一门课"只出现一次;
  • 不需要对应的 Python 类——它是幕后工作者,ORM 通过它跳转,但你日常操作看不到它。

secondary 参数 ​

普通的 relationship 靠外键直连两张表;多对多中间隔着一张表,需要用 secondary=enrollment 告诉 SQLAlchemy:"经由这张中间表去找到对方"。

模型定义(逐行讲解) ​

python
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)

与一对多的差别只有两点:

  1. 多了一张 Table 形式的中间表(注意它用的是第 2 章 Core 风格的 Table,不是映射类);
  2. relationship 里多了 secondary=enrollment。

使用示例 ​

python
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):

python
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 全自动;
  • 中间表要存额外字段时,升级为"关联对象模式"(拆成两个一对多)。