跳到主要内容

3.5 表关系:一对多与多对多

本章目标:掌握两种最常用的表关系的定义和使用。这是 SQLAlchemy 基础的最后一关,也是博客实战项目的数据模型基础。

1. 一对多:用户和文章

「一个用户有多篇文章,一篇文章属于一个用户」——这是最典型的一对多关系。

数据库层面靠外键实现:posts 表加一列 author_id 指向 users 表的 id。

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


class Base(DeclarativeBase):
pass


class User(Base):
__tablename__ = "users"

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

# 关系属性:user.posts → 该用户的所有文章(列表)
posts: Mapped[list["Post"]] = relationship(back_populates="author")


class Post(Base):
__tablename__ = "posts"

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

# 外键列:真实存在于数据库表中
author_id: Mapped[int] = mapped_column(ForeignKey("users.id"))

# 关系属性:post.author → 这篇文章的作者(单个 User 对象)
author: Mapped["User"] = relationship(back_populates="posts")

三个新面孔:

  1. ForeignKey("users.id"):外键列,值必须是 users 表某行的 id。注意写的是表名.列名(小写复数),不是类名
  2. relationship():定义「对象层面的关联属性」。它不是数据库列,只是让你能方便地在对象之间导航:post.authoruser.posts
  3. back_populates:把两边的 relationship 配成一对,指向对方的属性名。配对后两边自动同步——给 post.author 赋值,user.posts 里也会出现这篇文章

Mapped[list["Post"]] 里的引号:Post 类定义在后面,前向引用要加引号。

记忆模板:

「多」的一方:外键列 + relationship(单个对象)
「一」的一方:relationship(列表)
两边用 back_populates 互指对方的属性名

2. 一对多的使用

with SessionLocal() as db:
# 方式一:先建用户,再通过外键关联
user = User(username="xiaoming")
db.add(user)
db.commit()

post = Post(title="第一篇文章", content="...", author_id=user.id)
db.add(post)
db.commit()

# 方式二:直接用关系属性赋值,更面向对象(不用管 id)
post2 = Post(title="第二篇文章", author=user)
db.add(post2)
db.commit()

# 方式三:从「一」的那边追加
user.posts.append(Post(title="第三篇文章"))
db.commit() # user 已在 Session 中,新 post 会被级联保存

# ---- 读取 ----
p = db.get(Post, 1)
print(p.author.username) # 文章 → 作者:xiaoming

u = db.get(User, 1)
print([x.title for x in u.posts]) # 作者 → 文章列表:['第一篇文章', '第二篇文章', '第三篇文章']

懒加载与 N+1 问题(必看)

默认情况下 relationship懒加载(lazy load)db.get(Post, 1) 时不查作者,第一次访问 p.author 才发一条额外的 SQL。

这会导致经典性能陷阱 N+1 查询

posts = db.scalars(select(Post)).all() # 1 条 SQL 查所有文章
for p in posts:
print(p.author.username) # 每篇文章又发 1 条 SQL 查作者!100 篇 = 101 条 SQL

解决:查询时声明「把关联一起查出来」:

from sqlalchemy.orm import selectinload

posts = db.scalars(
select(Post).options(selectinload(Post.author))
).all() # 总共 2 条 SQL,无论多少篇文章
for p in posts:
print(p.author.username) # 不再触发额外查询

echo=True 对比两种写法的 SQL 数量,感受会非常直观。列表接口里要访问关联对象时,记得加 selectinload

级联删除

删除用户时,他的文章怎么办?在「一」的一方配置:

class User(Base):
...
posts: Mapped[list["Post"]] = relationship(
back_populates="author",
cascade="all, delete-orphan", # 删用户时连带删除其所有文章
)

不配置的话,删除用户会因外键约束报错(或留下孤儿数据,取决于数据库设置)。

3. 多对多:文章和标签

「一篇文章有多个标签,一个标签下有多篇文章」——多对多。

数据库层面需要一张中间表(关联表) post_tags,每行记录一个「文章-标签」配对:

posts post_tags tags
id title post_id tag_id id name
1 文章A 1 1 1 python
2 文章B 1 2 2 fastapi
2 1

SQLAlchemy 写法——中间表不需要建模型类,用 Table 定义即可:

from sqlalchemy import Column, ForeignKey, Integer, String, Table

# 中间表:只有两个外键,联合主键
post_tags = Table(
"post_tags",
Base.metadata,
Column("post_id", Integer, ForeignKey("posts.id"), primary_key=True),
Column("tag_id", Integer, 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, # 关键:指定中间表
back_populates="posts",
)


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",
)

使用起来就像操作普通列表,中间表完全由 SQLAlchemy 自动维护:

with SessionLocal() as db:
py = Tag(name="python")
fa = Tag(name="fastapi")

post = Post(title="FastAPI 入门")
post.tags.append(py) # 添加标签
post.tags.append(fa)
db.add(post)
db.commit()

# 反向访问
print([p.title for p in py.posts]) # 标签下的所有文章

# 移除一个标签(只删关联,不删 Tag 本身)
post.tags.remove(fa)
db.commit()

4. 一对一(了解)

一对一 = 一对多的特例,在「一」的那边加 uselist=False 或用单对象注解:

class User(Base):
...
profile: Mapped["Profile | None"] = relationship(back_populates="user")


class Profile(Base):
__tablename__ = "profiles"

id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), unique=True) # unique 保证一对一
user: Mapped["User"] = relationship(back_populates="profile")

5. 关系设计小抄

场景关系实现
用户 → 文章一对多多方加外键
文章 → 评论一对多多方加外键
文章 ↔ 标签多对多中间表 + secondary
用户 ↔ 关注用户多对多(自关联)中间表两列都指向 users
用户 → 详细资料一对一外键 + unique

本章小结

  • 一对多:多方放 ForeignKey,两边 relationship + back_populates 互指
  • 关系属性让你像访问普通属性一样导航对象:post.authoruser.posts
  • 警惕 N+1:循环访问关联前,用 selectinload 预加载
  • 多对多:Table 定义中间表,relationship 加 secondary=
  • 级联删除用 cascade="all, delete-orphan"

SQLAlchemy 基础完结!接下来把它和 FastAPI 组装成真正的项目:4.1 项目结构设计