☰
【Web全栈进阶】SQLAlchemy 2.0关系建模:一对多与多对多
2026/10/7 16:31:30 网站建设 项目流程

上一篇把数据搬进了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. 全程无报错后提交Git
gitadd.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仓库地址】

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询