上一篇把数据搬进了PostgreSQL,但早报站还在用手写SQL + sqlite3的旧通道。今天换新武器:SQLAlchemy 2.0正式进阶——数据层从“手写SQL时代”进入“ORM时代”,并且让数据第一次“长出关系”。
🎯本篇产出:users / articles / rss_sources / favorites四张表的ORM建模,一对多与多对多两种关系讲透。含代码约120行。
📌 太长不看版(给想快速上手的你)
| 项目信息 | 一句话说明 |
|---|---|
| 本篇目标 | SQLAlchemy 2.0 ORM关系建模 |
| 代码行数 | ~120行(模型 + 演示) |
| 依赖 | sqlalchemy+psycopg[binary] |
| 核心功能 | 四张表建模 + 一对多 + 多对多 |
| 跑起来的命令 | python demo_relations.py |
| 核心知识点 | Mapped类型注解、relationship、back_populates、secondary |
| 做完你能得到 | 数据层从“手写SQL”升级到“ORM时代”,关系建模一次打通 |
⚠️诚实的工程声明:早报站的网页层(Flask页面)暂时仍走旧通道,阶段三重构时统一切换到FastAPI + ORM——今天先把数据层的地基换好,这是正常工程的顺序:数据层先于表现层。
一、为什么是现在学关系建模
你见过SQLAlchemy的入门用法(Flask-SQLAlchemy的db.Column经典风格)。今天有两个升级:
| # | 升级 | 说明 |
|---|---|---|
| ① | 语法升级到2.0风格 | Mapped[str]类型注解驱动建模,Python类型即表结构——写模型像写数据类一样自然 |
| ② | 关系从零到一 | 早报站要变成多用户产品,数据就不再是“一张孤零零的文章表”——用户提交RSS源(一对多)、用户收藏文章(多对多),关系建模是这一切的地基 |
二、两分钟概念课:外键与relationship
关系建模只回答两个问题:
📖 问题①:数据层面——关系存在哪?——外键(ForeignKey)
📌 “谁引用谁”用外键列表达。一对多时,外键放在“多”的那一边(多条Source各自记着自己的user_id);多对多时,两边都放不下,需要一个第三张关联表记配对。
📖 问题②:代码层面——怎么顺着关系走?——relationship
📌 外键是给数据库看的,
relationship是给Python看的“导航属性”:user.sources直接拿到这个用户的全部源,不用手写JOIN。
| 关系 | 数据层 | 代码层 |
|---|---|---|
| 一对多(User → Source) | Source表存user_id外键 | user.sources返回列表 |
| 多对多(User ↔ Article) | 第三张favorites表记配对 | user.favorite_articles返回列表 |
🎯外键决定数据怎么存,relationship决定代码怎么写——两者配合,JOIN从你的代码里消失了。
三、第0步:setup与目录
pipinstallsqlalchemy"psycopg[binary]"早报站项目加两个模块(沿用之前的结构规矩):
python_daily/ ├── core/ │ ├── db.py # 引擎与会话(新增) │ ├── models.py # ORM模型(新增) │ ├── storage.py # 旧通道(阶段三退役) │ └── ...四、第1步:连接引擎与会话——把“连库”变成“会话”
core/db.py:
"""core/db.py —— 引擎与会话"""fromsqlalchemyimportcreate_enginefromsqlalchemy.ormimportsessionmaker DATABASE_URL="postgresql+psycopg://postgres:你的密码@localhost:5432/daily"engine=create_engine(DATABASE_URL)Session=sessionmaker(bind=engine)对比第17篇的Flask-SQLAlchemy:那里db.session是框架替你藏好的;这里显式化了——engine是“连库的通道”,Session是“一次工作的现场”。
💡 tmp_path夹具思想在这里的对应物:每次业务操作开一个会话,用完即关(with块自动关)。
五、第2步:四个模型——类型注解即表结构
core/models.py:
"""core/models.py —— 早报站ORM模型(SQLAlchemy 2.0)"""fromdatetimeimportdatetimefromsqlalchemyimportColumn,DateTime,ForeignKey,Table,funcfromsqlalchemy.ormimportDeclarativeBase,Mapped,mapped_column,relationshipclassBase(DeclarativeBase):pass# 多对多关联表:user_id + article_id记录"谁收藏了哪篇"favorites=Table("favorites",Base.metadata,Column("user_id",ForeignKey("users.id"),primary_key=True),Column("article_id",ForeignKey("articles.id"),primary_key=True),Column("created_at",DateTime,server_default=func.now()),)classUser(Base):__tablename__="users"id:Mapped[int]=mapped_column(primary_key=True)username:Mapped[str]=mapped_column(unique=True)# 一对多:一个用户拥有多个订阅源sources:Mapped[list["Source"]]=relationship(back_populates="user")# 多对多:一个用户收藏多篇文章(经由favorites表)favorite_articles:Mapped[list["Article"]]=relationship(secondary=favorites,back_populates="favorited_by")classSource(Base):__tablename__="rss_sources"id:Mapped[int]=mapped_column(primary_key=True)url:Mapped[str]user_id:Mapped[int]=mapped_column(ForeignKey("users.id"))# 多的那一边,用back_populates和对面互相指认user:Mapped["User"]=relationship(back_populates="sources")classArticle(Base):__tablename__="articles"# 上一篇建的表现在由ORM接管id:Mapped[int]=mapped_column(primary_key=True)url:Mapped[str]=mapped_column(unique=True)title:Mapped[str]date:Mapped[str|None]=mapped_column(nullable=True)summary:Mapped[str]=mapped_column(default="")favorited_by:Mapped[list["User"]]=relationship(secondary=favorites,back_populates="favorite_articles")📖 逐段读懂这段代码
| 概念 | 说明 |
|---|---|
Mapped[str] | 类型注解即列定义——Mapped[int]是整数主键,Mapped[str]是非空文本,str | None表示可空列。读模型就像读数据结构,这是2.0风格的全部魔法 |
favorites关联表 | 多对多的“配对本”,两个外键联合主键保证“同一对收藏不重复” |
relationship(secondary=favorites) | 告诉ORM“User和Article之间隔着favorites表走”——写代码时完全感觉不到这张表的存在 |
back_populates | 关系的两端互相指认,ORM才知道user.sources和source.user是同一枚硬币的两面 |
建表
fromcore.dbimportenginefromcoreimportmodels models.Base.metadata.create_all(engine)# 新增users / rss_sources / favorites💡
create_all只补缺的——上一篇已建的articles表不会被动。
可以在main.py中添加新的命令,也可以创建新的脚本运行建库脚本。
六、第3步:关系实战——写数据的正确姿势
demo_relations.py:
"""demo_relations.py —— 关系建模实战演示"""fromsqlalchemyimportselectfromcore.dbimportSessionfromcore.modelsimportArticle,Source,Userdefmain()->None:# ① 一对多:新建用户 + 挂上两个订阅源withSession()assession:u=User(username="博主本人")u.sources.append(Source(url="https://coolshell.cn/feed"))u.sources.append(Source(url="https://www.ruanyifeng.com/blog/atom.xml"))session.add(u)session.commit()# ② 多对多:让这个用户收藏两篇文章withSession()assession:u=session.scalar(select(User).where(User.username=="博主本人"))forarticleinsession.scalars(select(Article).limit(2)):u.favorite_articles.append(article)session.commit()# ③ 顺着关系读数据:不写一行JOINwithSession()assession:u=session.scalar(select(User).where(User.username=="博主本人"))print(f"用户「{u.username}」订阅了{len(u.sources)}个源:")forsinu.sources:print(" -",s.url)print(f"收藏了{len(u.favorite_articles)}篇文章,"f"第一篇是《{u.favorite_articles[0].title}》")if__name__=="__main__":main()🎯 三个关键姿势
| # | 姿势 | 说明 |
|---|---|---|
| ① | 用append建立关系,不要手填外键 | u.sources.append(Source(...))时ORM自动把user_id填进Source——手填user_id=1的写法一旦用户id猜错就是外键约束报错(第八节③) |
| ② | session.scalar(select(...)) | 2.0风格的查询——select构造查询,scalar取单条,scalars取多条。对比1.x的session.query,2.0是“构造式”查询,与类型系统配合更好 |
| ③ | 关系的读写都在会话内完成 | with Session()块里能顺着关系取数据,出了块再访问u.sources就是“脱离会话访问”(第八节⑤的经典报错) |
✅ 运行输出
用户「博主本人」订阅了 2 个源: - https://coolshell.cn/feed - https://www.ruanyifeng.com/blog/atom.xml 收藏了 2 篇文章,第一篇是《……》七、验收清单
1. create_all后psql\dt → 看到users / rss_sources / favorites三张新表2. demo_relations.py跑通 → 输出订阅源与收藏列表3. psql验证favorites表:两行配对记录,created_at自动填充4. 重跑demo → username已存在会怎样?(练习①的思考题)5. 全程无报错后提交Gitgitadd.gitcommit-m"数据层升级:SQLAlchemy 2.0关系建模"八、常见报错:这6个,关系建模的标配(重点!)
①ModuleNotFoundError: No module named 'sqlalchemy'
🔍 原因:没装或装错环境。
✅ 解法:老朋友三连——看(venv)前缀 →python -m pip list→pip install sqlalchemy "psycopg[binary]"。
②sqlalchemy.exc.OperationalError: connection refused
🔍 原因:PostgreSQL容器没在跑——上一篇的成果没续上。
✅ 解法:
dockerps# 看容器状态容器没了就docker run重新拉起(数据卷还在,数据一条不少)。
③sqlalchemy.exc.IntegrityError: FOREIGN KEY constraint failed
🔍 原因:手填了不存在的user_id(比如Source(user_id=999))。
✅ 解法:改用append建立关系(第6节姿势①)——让ORM替你填外键,永远不错。
④ 多对多报ArgumentError: relationship ... expects a class or mapper
🔍 原因:favorites关联表定义在relationship之后,或拼写不一致。
✅ 解法:关联表要在使用它的relationship之前定义;核对secondary=favorites的名字与Table变量一致。
⑤sqlalchemy.orm.exc.DetachedInstanceError: Instance is not bound to a Session
🔍 原因:会话关闭后访问懒加载关系——with块外读u.sources,ORM想查数据库却找不到会话。
✅ 解法:关系读取全部放进with Session()块内;需要“带出”数据的场景用selectinload预加载(进阶,第16篇测试策略会再见到)。
⑥ 查询结果打印出来是(1, '博主本人')元组而不是对象
🔍 原因:session.execute(select(...))默认返回行(Row),不是模型对象。
✅ 解法:要对象用session.scalar()/session.scalars();要“只取某几列”才用execute+ 行。
📌2.0风格的“取数形状”由你说了算。
九、课后练习
| # | 练习 | 难度 | 提示 |
|---|---|---|---|
| 1 | 幂等思考题:demo重跑会怎样?给User.username的unique约束一个说法 | ⭐⭐ | 先查后建,或接受报错——INSERT ... ON CONFLICT的思路在ORM里是session.merge |
| 2 | 收藏排行榜:查出被收藏次数最多的3篇文章 | ⭐⭐⭐ | select(Article, func.count(...)).join(...).group_by(...)——ORM里JOIN长这样 |
| 3 | 级联删除:给User.sources加cascade="all, delete-orphan",删用户时订阅源自动清理 | ⭐⭐⭐ | 对比默认"不许删"的行为差异 |
| 4(选做) | 旧脚本ORM化:把迁移脚本改写为“SQLAlchemy读SQLite + 写PG” | ⭐⭐⭐⭐ | 感受ORM抹平方言差异 |
📦 配套代码
完整模型与演示脚本已上传Git(python_daily/):【gitee仓库地址】