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")
三个新面孔:
ForeignKey("users.id"):外键列,值必须是 users 表某行的 id。注意写的是表名.列名(小写复数),不是类名relationship():定义「对象层面的关联属性」。它不是数据库列,只是让你能方便地在对象之间导航:post.author、user.postsback_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.author、user.posts - 警惕 N+1:循环访问关联前,用
selectinload预加载 - 多对多:
Table定义中间表,relationship 加secondary= - 级联删除用
cascade="all, delete-orphan"
SQLAlchemy 基础完结!接下来把它和 FastAPI 组装成真正的项目:4.1 项目结构设计