跳到主要内容

4.3 多对多关系

一篇文章有多个标签,一个标签也属于多篇文章——这就是多对多。它需要引入一个新角色:中间表(关联表)

一、为什么需要中间表?

多对多没法靠一个外键解决:外键放 posts 表?一篇文章只能存一个 tag_id。放 tags 表?同理。

答案:单独建一张中间表,每行记录一个"配对"

posts 表 post_tags 中间表 tags 表
┌────┬───────┐ ┌─────────┬────────┐ ┌────┬────────┐
│ id │ title │ │ post_id │ tag_id │ │ id │ name │
├────┼───────┤ ├─────────┼────────┤ ├────┼────────┤
│ 1 │ 文章A │ │ 1 │ 1 │ │ 1 │ Python │
│ 2 │ 文章B │ │ 1 │ 2 │ │ 2 │ 数据库 │
└────┴───────┘ │ 2 │ 2 │ │ 3 │ 随笔 │
└─────────┴────────┘ └────┴────────┘

含义:文章A 有 [Python, 数据库] 两个标签;文章B 有 [数据库] 标签

中间表的每一行 = 一条"某文章拥有某标签"的记录。两列合起来做主键,天然防止同一配对重复插入。

二、SQLAlchemy 实现

中间表通常没有业务字段,不需要为它建模型类,用轻量的 Table 对象定义即可:

from sqlalchemy import Column, ForeignKey, String, Table, create_engine
from sqlalchemy.orm import (DeclarativeBase, Mapped, mapped_column,
relationship, sessionmaker)


class Base(DeclarativeBase):
pass


# ---------- 中间表:纯 Table 对象,不是模型类 ----------
post_tags = Table(
"post_tags",
Base.metadata,
Column("post_id", ForeignKey("posts.id"), primary_key=True),
Column("tag_id", ForeignKey("tags.id"), primary_key=True),
)


class Post(Base):
__tablename__ = "posts"

id: Mapped[int] = mapped_column(primary_key=True)
title: Mapped[str] = mapped_column(String(200))

tags: Mapped[list["Tag"]] = relationship(
secondary=post_tags, # ← 关键:告诉 SQLAlchemy 走哪张中间表
back_populates="posts",
)

def __repr__(self) -> str:
return f"Post(id={self.id}, title={self.title!r})"


class Tag(Base):
__tablename__ = "tags"

id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(50), unique=True)

posts: Mapped[list["Post"]] = relationship(
secondary=post_tags,
back_populates="tags",
)

def __repr__(self) -> str:
return f"Tag(id={self.id}, name={self.name!r})"


engine = create_engine("sqlite:///m2m.db", echo=True)
SessionLocal = sessionmaker(bind=engine)
Base.metadata.create_all(engine)

要点:

  • 中间表用 Table(...) 定义,挂在 Base.metadata 上,两个外键列共同组成主键
  • 两侧 relationship 都写 secondary=post_tags,都是 Mapped[list[...]]
  • 你几乎永远不用直接操作中间表——SQLAlchemy 全自动维护它

三、使用:像操作列表一样

添加关联

with SessionLocal() as session:
python_tag = Tag(name="Python")
db_tag = Tag(name="数据库")
essay_tag = Tag(name="随笔")

post_a = Post(title="SQLAlchemy 入门")
post_a.tags.append(python_tag) # 就是普通的列表操作!
post_a.tags.append(db_tag)

post_b = Post(title="MySQL 调优笔记", tags=[db_tag]) # 创建时直接传

session.add_all([post_a, post_b])
session.commit()

commit 时 SQLAlchemy 自动往 post_tags 里插了 3 行配对记录,全程无感。

读取

with SessionLocal() as session:
post = session.get(Post, 1)
print([t.name for t in post.tags]) # ['Python', '数据库']

tag = session.scalar(select(Tag).where(Tag.name == "数据库"))
print([p.title for p in tag.posts]) # ['SQLAlchemy 入门', 'MySQL 调优笔记']

解除 / 修改关联

with SessionLocal() as session:
post = session.get(Post, 1)
tag = session.scalar(select(Tag).where(Tag.name == "Python"))

post.tags.remove(tag) # 只删除中间表的配对记录,Post 和 Tag 本体都还在!
session.commit()

# 整体替换标签
post.tags = [session.scalar(select(Tag).where(Tag.name == "随笔"))]
session.commit()

📌 多对多的删除语义post.tags.remove(tag)session.delete(post) 都只影响中间表的配对记录,不会误删 Tag 本体。这也是为什么多对多关系不要配 delete-orphan(标签是共享的,不从属于任何一篇文章)。

四、查询

from sqlalchemy import select

# 查带"数据库"标签的所有文章
stmt = select(Post).where(Post.tags.any(Tag.name == "数据库"))

# 查没有任何标签的文章
stmt = select(Post).where(~Post.tags.any())

# join 写法(跨中间表 join,SQLAlchemy 自动处理两次 JOIN)
stmt = select(Post).join(Post.tags).where(Tag.name == "数据库")

五、进阶预告:中间表需要额外字段怎么办?

如果配对本身有业务数据——比如"学生选课"要记录成绩选课时间

student_idcourse_idscoreenrolled_at

这时 secondary 方式就不够了,需要把中间表升级为正式模型类(关联对象模式,Association Object):把多对多拆成两个一对多(Student 1—N Enrollment N—1 Course)。

class Enrollment(Base):
__tablename__ = "enrollments"

student_id: Mapped[int] = mapped_column(ForeignKey("students.id"), primary_key=True)
course_id: Mapped[int] = mapped_column(ForeignKey("courses.id"), primary_key=True)
score: Mapped[Optional[int]]

student: Mapped["Student"] = relationship(back_populates="enrollments")
course: Mapped["Course"] = relationship(back_populates="enrollments")

先知道有这个模式即可,需要时再深入。

六、三种关系终极对照表

关系例子外键位置relationship 注解
一对多用户→文章"多"方(posts.user_id)一方 list["Post"],多方 "User"
一对一用户→资料任一方 + unique=True两侧都是单数(一侧 Optional)
多对多文章↔标签都不放,放中间表两侧都是 list[...] + secondary=

📝 本节小结

  • 多对多必须有中间表,每行是一个配对;两列外键共同做主键
  • 中间表用 Table() 定义;两侧 relationship 加 secondary=中间表
  • 增删关联就是操作 Python 列表(append/remove),中间表全自动维护
  • 删除只断关系不删本体;多对多不配 delete-orphan
  • 中间表要带业务字段时,改用"关联对象"模式

下一节解决关系的性能问题 → 4.4 关系加载策略与 N+1 问题