ORM 建模
本章你将学会 - 用 DeclarativeBase + Mapped + mappedcolumn 三件套声明模型 - 主键 / 自增 / 可空 / 默认值 / 唯一 / 索引的每一种写法 - 外键 Forei…
本章你将学会
- 用
DeclarativeBase+Mapped+mapped_column三件套声明模型- 主键 / 自增 / 可空 / 默认值 / 唯一 / 索引的每一种写法
- 外键
ForeignKey与ondelete删除策略- 一对多、多对多:
relationship、back_populates、secondary- 用 Mixin 复用公共字段,用
TYPE_CHECKING防循环导入- JSONB 等 PG 特有类型,以及
create_all的真实定位
上一章我们把"路"修好了:引擎管连接池,会话管事务。这一章开始"盖房子"——用 Python 类把表结构写出来,SQLAlchemy 负责翻译成 CREATE TABLE。本章自始至终围绕一个内容平台的小案例:栏目(Channel)、文章(Article)、标签(Tag)、文章版本(ArticleVersion),它们刚好把常用的建模姿势全用上了。
1. 心智模型:类是图纸,实例是砖
ORM 建模只有一句话要记:
类 ↔ 表;类属性 ↔ 列;类实例 ↔ 一行数据。
class Article(Base): # 对应表 article
title: Mapped[str] # 对应列 title
...
# 查出来的每个对象,就是表里的一行
article = Article(title="第一篇文章") # 还没入库,只是图纸上的规划
打个比方:模型类是图纸,Base.metadata 是市政局的档案柜——每定义一个模型类,SQLAlchemy 就自动把这张图纸登记进档案柜。后面不管是一键建表(create_all)还是跑迁移(第 07 章的 Alembic),都是照着档案柜里的图纸开工。图纸没登记,就没人为你施工。
2. DeclarativeBase:所有模型的"户籍管理处"
作用:所有模型类的共同祖先。继承它,SQLAlchemy 才知道"这是一个表模型",才会把表登记进 metadata 档案柜。
签名(无参数,按继承使用):
class DeclarativeBase:
# 可在子类中定制的类属性:metadata、registry、type_annotation_map
最小示例:
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
pass
项目里的第一个模型文件,一般就长这样。需要定制时再传类属性,比如全局类型映射:
from sqlalchemy import MetaData
from sqlalchemy.orm import DeclarativeBase
class Base(DeclarativeBase):
metadata = MetaData(naming_convention={ # 统一约束命名,生产环境推荐
"ix": "ix_%(column_0_label)s",
"uq": "uq_%(table_name)s_%(column_0_name)s",
})
常见坑:
- 一个项目只建一个
Base。Base可以建多个,但每个Base各有一个独立档案柜(metadata),create_all和 Alembic 都只认自己那个柜子——模型挂错了户口,表就"失踪"了。 - 模型必须被 import 过才存在于档案柜。定义了
Article类但没被任何代码导入,create_all根本不知道它存在(第 14 节细说)。 - 普通的工具基类不要继承
Base。只有"确实要映射成表"的类才继承;复用公共字段用 Mixin(第 12 节),Mixin 自己是普通类。
3. __tablename__:给表起名字
作用:指定模型对应的表名。不写也行——SQLAlchemy 会拿类名当表名,但显式控制更稳。
签名(declarative 指令式属性):
__tablename__: str # 手动指定
# 或用 declared_attr.directive 做自动生成
最小示例——手动写:
class Channel(Base):
__tablename__ = "channel"
自动生成(推荐做法,全项目统一风格):
from sqlalchemy.orm import DeclarativeBase, declared_attr
class Base(DeclarativeBase):
@declared_attr.directive
def __tablename__(cls) -> str:
return cls.__name__.lower() # Channel -> "channel"
写成 declared_attr.directive 的意思是:"这是个给 declarative 机制看的指令属性,不是要映射成列的属性"。子类继承时它都会重新执行一次,各自算各自的表名。
常见坑:
- 自动小写对
ArticleVersion这种驼峰名会得到articleversion(没有下划线)。嫌丑就在子类里手动覆盖:__tablename__ = "article_version",覆盖优先级高于自动生成。 - 别用数据库保留字当表名:
user、order这类词在 PG 里是关键字,直接建表会翻车,起名users、orders绕开。 - 表名会出现在
ForeignKey("表名.列名")的字符串里,改表名时记得全局搜一遍这些字符串引用。
4. Mapped + mapped_column:类型标注驱动的列定义
作用:这是 SQLAlchemy 2.0 的核心建模姿势。Mapped[类型] 注解决定列的"基础类型和可空性",mapped_column() 补充细节(长度、唯一、索引、默认值……)。相当于看身份证定工种,再按岗位细则派活。
签名(手册式精简版,只列高频参数):
mapped_column(
类型 或 ForeignKey(...), # 第一个位置参数:显式指定列类型或外键
*,
primary_key=False, # 是否主键
nullable=None, # 是否可空(默认由注解推断)
default=None, # Python 端默认值(flush 时填充)
server_default=None, # 数据库端默认值(写进 DDL 的 DEFAULT)
onupdate=None, # UPDATE 时自动刷新的值
index=False, # 建普通索引
unique=False, # 建唯一约束
)
最小示例——三种写法,从简到繁:
class Tag(Base):
# 写法一:只要注解,零配置(str -> String,非空)
name: Mapped[str] = mapped_column(String(50), unique=True)
# 写法二:注解 + 空 mapped_column(),纯占位,将来加参数方便
note: Mapped[str] = mapped_column()
# 写法三:可空列——Optional 注解自动推导 nullable=True
remark: Mapped[str | None] = mapped_column(String(200))
类型注解和列类型的对应关系(默认映射表):
| Python 类型注解 | 默认列类型 |
|---|---|
Mapped[int] |
Integer |
Mapped[float] |
Float |
Mapped[bool] |
Boolean |
Mapped[str] |
String(无长度限制) |
Mapped[bytes] |
LargeBinary |
Mapped[datetime] |
DateTime |
Mapped[date] / Mapped[time] |
Date / Time |
Mapped[timedelta] |
Interval |
Mapped[Decimal] |
Numeric |
Mapped[uuid.UUID] |
Uuid |
Mapped[dict] / Mapped[list] |
JSON |
常见坑:
Mapped[str]默认映射成不带长度的 String:PG、SQLite 无所谓,MySQL 会直接报错(VARCHAR 必须给长度)。跨库项目养成习惯:字符串列显式写String(50)。- 可空性由注解推断,别两边打架:
Mapped[str | None]自动nullable=True;这时再手写nullable=False是自相矛盾,以显式参数为准,但读代码的人会懵。一个原则:注解管可空,参数管细节。 - 注解必须真的写在类型里:
Mapped[str]和Mapped[str | None]是两种列,写错一个| None,数据库约束就完全变了。 - 想让自定义类型也自动映射?在
DeclarativeBase子类上传type_annotation_map扩展映射表,本章不展开。
5. 主键与自增
作用:primary_key=True 声明主键;整型主键默认自增,插入时不用手动给值。
签名:
mapped_column(Identity(*, start=None, increment=None, always=False),
primary_key=True, autoincrement="auto")
最小示例:
class Channel(Base):
# 最常见:整型自增主键(整型主键默认自增,不用额外参数)
id: Mapped[int] = mapped_column(primary_key=True)
# PG / Oracle 风格:标准 IDENTITY 子句(与上面二选一)
# id: Mapped[int] = mapped_column(Identity(), primary_key=True)
Identity() 生成的是 GENERATED BY DEFAULT AS IDENTITY——PG 10+、Oracle、MariaDB 10.3+ 都认;SQLite 这类不支持的库会安静地忽略它,退回普通的整型主键自增(本章 demo 在 aiosqlite 上就是这么跑的,行为完全正常)。
常见坑:
- 整型单列主键默认就是自增(
autoincrement="auto"),不写Identity()也行。写它的意义是"声明式地表达我要数据库端自增",在 PG 上更标准。 autoincrement=False可以关掉自增——比如 id 由外部系统发号时。- 想用 UUID 主键:
Mapped[uuid.UUID] = mapped_column(Uuid, primary_key=True, default=uuid.uuid4)(Python 端生成),或 PG 13+ 上server_default=func.gen_random_uuid()(数据库端生成)。 - 复合主键(多列联合主键)见第 10 节,写法不一样。
6. 可空与默认值:default 和 server_default 是两套机制
作用:给列配默认值。名字看着像兄弟,其实是两家人:
default=...:Python 端默认值。ORM 在 flush 生成 INSERT 语句时替你补上,不会出现在 DDL 里。server_default=...:数据库端默认值。翻译成CREATE TABLE里的DEFAULT ...子句,谁来插行都生效。
打个比方:default 是发货员(ORM)发货时顺手塞进包裹的赠品——绕过这个发货员(直接用 psql 手写 INSERT)就没有了;server_default 是写进仓库规章制度的赠品规则——不管谁来发货,仓库都照章办事。
签名:
mapped_column(..., default=Python值或可调用对象,
server_default=SQL表达式或字符串,
onupdate=Python值或SQL表达式)
最小示例:
from datetime import datetime
from sqlalchemy import DateTime, String, Text, func
class Article(Base):
# Python 端默认值:默认空字符串
content: Mapped[str] = mapped_column(Text, default="")
# 可空列:不写摘要就不写,数据库里存 NULL
summary: Mapped[str | None] = mapped_column(String(500))
# 数据库端默认值:DDL 里会有 DEFAULT CURRENT_TIMESTAMP
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
# 版本号:Python 端默认 1
version_no: Mapped[int] = mapped_column(default=1)
onupdate 是 default 的"更新版":ORM 执行 UPDATE 时自动把列刷新成新值(常配 func.now() 做 updated_at,第 12 节的 TimestampMixin 就是这么写的)。
常见坑:
- 类型限制:
server_default只接受 SQL 表达式(func.now()、text("''"))或字符串字面量,直接塞 Python 对象(如datetime.now())会报错。Python 对象是给default=用的。 - 传可调用对象时别加括号:
default=datetime.now传的是函数本身,每次插入时才调用、各自拿时间;default=datetime.now()带括号传的是"模块加载那一刻"的固定结果——所有行共用一个时间戳,新手重灾区。 - 改
default不影响已有数据:它只作用于之后的 INSERT。想让老数据也补上默认值,得跑一次 UPDATE(第 06 章)。 onupdate只在 ORM 更新时触发。别人直接UPDATE ... WHERE ...的裸 SQL 不会刷新它,别指望它 100% 可靠。
7. 唯一与索引:unique、index、__table_args__
作用:给列加约束或索引。unique=True 唯一约束(数据库层面拦截重复),index=True 普通索引(加速查询,不约束数据)。
签名:
mapped_column(..., unique=False, index=False)
# 类级别:多列组合约束
__table_args__ = (
UniqueConstraint("列1", "列2", name="约束名"),
Index("索引名", "列1", "列2"),
)
最小示例:
from sqlalchemy import Index, String, UniqueConstraint
class Tag(Base):
name: Mapped[str] = mapped_column(String(50), unique=True) # 单列唯一
class ArticleVersion(Base):
__tablename__ = "article_version"
# 组合唯一:同一篇文章里,版本号不能重复
__table_args__ = (
UniqueConstraint("article_id", "version_no", name="uq_article_version_no"),
Index("ix_version_article", "article_id"), # 显式命名的普通索引
)
article_id: Mapped[int] = mapped_column(ForeignKey("article.id"))
version_no: Mapped[int] = mapped_column(default=1)
建出来的 DDL 长这样(demo 实测):
CONSTRAINT uq_article_version_no UNIQUE (article_id, version_no)
常见坑:
- 外键列在 PG 上不会自动建索引(MySQL 会)。按外键查询是常态,建议外键列加
index=True。 index=True自动生成的索引名各库有各的规则,生产环境想统一管理,用__table_args__里的Index("ix_名字", "列")显式命名。- 唯一约束撞上可空列:PG 里
NULL不参与唯一比较,多行NULL是合法的。想"最多一个 NULL"得用部分索引(Index(..., postgresql_where=...)),进阶话题,先知道有这回事。 - 唯一约束在插入重复时抛
IntegrityError,这不是 bug,是约束在干活。怎么优雅地接住它,第 09 章统一异常处理见。
8. ForeignKey 与 ondelete:外键及其"应急预案"
作用:ForeignKey("表.列") 声明外键,把两张表焊在一起;ondelete 指定父行被删除时,子行怎么办。
签名:
ForeignKey("表名.列名", ondelete=None) # ondelete: CASCADE / RESTRICT / SET NULL / SET DEFAULT
最小示例:
class Article(Base):
# 文章必须挂在一个栏目下;栏目还有文章时,栏目不许删(RESTRICT 拦截)
channel_id: Mapped[int] = mapped_column(
ForeignKey("channel.id", ondelete="RESTRICT")
)
class ArticleVersion(Base):
__tablename__ = "article_version"
# 文章删了,它的历史版本没存在意义,跟着删
article_id: Mapped[int] = mapped_column(
ForeignKey("article.id", ondelete="CASCADE")
)
四种删除策略,用人话翻译:
| 策略 | 含义 | 适用场景 |
|---|---|---|
CASCADE |
父删,子陪葬 | 版本记录、日志这种附属数据 |
RESTRICT |
有子在,父别想走 | 栏目下还有文章 → 拦住删除 |
SET NULL |
父删,子断联(列必须可空) | 可有可无的关联 |
SET DEFAULT |
父删,子回默认值 | 少见 |
常见坑:
- 参数是字符串
"表名.列名",不是 Python 类或属性——因为定义Article时,channel表可能还没被声明。字符串引用让 SQLAlchemy 事后解析。 ondelete是数据库端行为,生效前提是数据库真的执行外键约束:PG 默认执行;SQLite 默认不执行(要开PRAGMA foreign_keys=ON),demo 里不深究,生产上 PG 无此烦恼。- 还有另一套删除语义在 ORM 层:
relationship(cascade=...)(下一节)。新手最容易混:ondelete管数据库,relationship的cascade管 ORM 内存操作。生产建议两层配套:外键ondelete="CASCADE"+ relationshippassive_deletes=True,让数据库干脏活,ORM 少发 SQL。
9. 一对多:relationship + back_populates
作用:relationship 在两个模型之间架一座内存中的桥——让你用 article.channel、channel.articles 这种属性访问代替手写 JOIN。注意:它只声明对象关系,不产生任何列。建表时数据库只认 mapped_column。
签名(高频参数):
relationship(
"目标类名", # 也可省略,由注解推断
*,
back_populates=None, # 对方模型里"回指"的属性名
secondary=None, # 多对多专用,见第 10 节
cascade=None, # ORM 级联:"all, delete-orphan" 等
lazy="select", # 加载策略,第 06 章细讲
passive_deletes=False, # 把删除交给数据库的 ondelete
)
最小示例——栏目与文章,一对多(多对一是同一个关系的两面):
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
class Channel(Base):
...
# 一个栏目 → 多篇文章:list["Article"]
articles: Mapped[list["Article"]] = relationship(back_populates="channel")
class Article(Base):
...
channel_id: Mapped[int] = mapped_column(ForeignKey("channel.id", ondelete="RESTRICT"))
# 一篇文章 → 一个栏目:单对象
channel: Mapped["Channel"] = relationship(back_populates="articles")
用起来是什么体验:
article = Article(title="第一篇", channel=Channel(name="后端"))
# ↑ 给了对象,channel_id 由 ORM 在 flush 时自动补上
channel.articles # 直接拿到这个栏目下的所有文章对象
back_populates 是双向对讲机:两边必须互相指认("channel" ↔ "articles"),任何一边改了,另一边内存里同步更新。
常见坑:
- 注解方向别写反:"多"的一方拿单对象(
Mapped["Channel"]),"一"的一方拿列表(Mapped[list["Article"]])。写反了语义就崩了。 - 引号别省:
Mapped[list["Article"]]里目标类是字符串(前向引用);类定义有先后,字符串让 SQLAlchemy 事后解析。 back_populates指的属性名必须真实存在且互相指认,拼错会在启动时直接报 mapper 配置错误——这反而是好事,早失败早发现。- 老代码里常见的
backref=(单向声明自动生成反向)是隐式风格,2.0 官方更推荐back_populates显式双向,两边一眼看清。 - 异步会话里不能懒加载:默认
lazy="select"意味着访问属性时才发 SQL,而 asyncio 里这个隐式 IO 会抛MissingGreenlet。查询时用selectinload/joinedload预加载,第 06 章展开。
10. 多对多:secondary 关联表与复合主键
作用:文章和标签是多对多——一篇文章多个标签,一个标签贴多篇文章。关系数据库表达不了"直接多对多",必须拆出一个中间表,把一个 M:N 拆成两个 1:N。
中间表用 Core 风格的 Table 定义(它没有自己的业务行为,不需要映射成类):
签名:
Table(
"表名",
Base.metadata, # 挂到 Base 的档案柜
Column("article_id", ForeignKey("article.id"), primary_key=True),
Column("tag_id", ForeignKey("tag.id"), primary_key=True),
)
# 两列同时 primary_key=True → 复合主键:(article_id, tag_id) 组合唯一
最小示例——完整的"文章 × 标签":
from sqlalchemy import Column, ForeignKey, String, Table
from sqlalchemy.orm import Mapped, mapped_column, relationship
article_tag = Table(
"article_tag",
Base.metadata,
Column("article_id", ForeignKey("article.id", ondelete="CASCADE"), primary_key=True),
Column("tag_id", ForeignKey("tag.id", ondelete="CASCADE"), primary_key=True),
)
class Article(Base):
...
tags: Mapped[list["Tag"]] = relationship(
secondary=article_tag, back_populates="articles"
)
class Tag(Base):
...
articles: Mapped[list["Article"]] = relationship(
secondary=article_tag, back_populates="tags"
)
中间表像一张群聊成员表:article_tag 每一行就是"某文章加入了某标签群"。建表 DDL(demo 实测):
CREATE TABLE article_tag (
article_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (article_id, tag_id), -- 复合主键
FOREIGN KEY(article_id) REFERENCES article (id) ON DELETE CASCADE,
FOREIGN KEY(tag_id) REFERENCES tag (id) ON DELETE CASCADE
)
用起来:
article.tags = [Tag(name="Python"), Tag(name="SQLAlchemy")]
await session.commit()
# ORM 自动往 article_tag 里写两行,不用你管
常见坑:
- 关联表行由 ORM 自动维护:给
article.tags赋列表,flush 时自动 INSERT 中间表行;从列表里移除,自动 DELETE。别自己手动往article_tag插数据,两条管理路线会打架。 - 复合主键的写法就是多个
Column(..., primary_key=True)。它天然保证"同一对 (article_id, tag_id) 只出现一次",重复贴标签会撞唯一约束。 - 什么时候中间表要升级成模型类?当关联行自己要带字段时(比如"什么时候打的标签")。这时放弃
secondary,把中间表定义成普通模型类,两边各写一个一对多——这叫 association object 模式。判据一句话:关联本身有没有属性。 - 中间表也记得挂
Base.metadata,挂了才会被create_all建出来。
11. 模型拆文件时:TYPE_CHECKING 防循环导入
模型多了一个文件塞不下,自然会按"一表一文件"拆开。问题来了:article.py 要用 Channel,channel.py 要用 Article——你 import 我,我 import 你,Python 直接抛 ImportError(循环导入)。
解法是注解用字符串、导入放条件块:
# model/article.py
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from model.association import article_tag
from model.base import Base, IDMixin, TimestampMixin
if TYPE_CHECKING:
# 这个块只在类型检查器(mypy / IDE)里执行,运行时不会导入
from model.channel import Channel
from model.tag import Tag
class Article(Base, IDMixin, TimestampMixin):
title: Mapped[str] = mapped_column(String(200), index=True)
channel_id: Mapped[int] = mapped_column(ForeignKey("channel.id", ondelete="RESTRICT"))
# 引号字符串 = 前向引用,SQLAlchemy 运行时自己解析,不需要真 import
channel: Mapped["Channel"] = relationship(back_populates="articles")
tags: Mapped[list["Tag"]] = relationship(secondary=article_tag, back_populates="articles")
打个比方:if TYPE_CHECKING: 块相当于只在编辑器里递名片——IDE 拿着名片能帮你补全、查类型;运行时大家互不串门,循环导入自然解除。SQLAlchemy 解析关系靠的是字符串名,不靠 import。
常见坑:
- 只需要类型出现在注解里的,放
TYPE_CHECKING;运行时真要用类本身的(比如继承、secondary=传表对象),必须正常导入。secondary=article_tag传的是表对象,所以要真导入。 - 字符串前向引用别忘引号:
Mapped["Channel"],不带引号运行时就找不到名字了。 - 建表入口必须把所有模型 import 一遍(哪怕只是
# noqa: F401占位),否则档案柜缺图纸,create_all建不全表。第 14 节再强调。
12. Mixin:公共字段的"预制件"
作用:id、created_at、updated_at 这种字段每张表都要,每张表抄一遍就是重复劳动。Mixin 把它们做成预制墙板——浇筑到每栋楼(每个模型)里各自成型,改一处,全楼受益。
最小示例:
# model/base.py
from datetime import datetime
from sqlalchemy import DateTime, Identity, func
from sqlalchemy.orm import DeclarativeBase, Mapped, declared_attr, mapped_column
class Base(DeclarativeBase):
@declared_attr.directive
def __tablename__(cls) -> str:
return cls.__name__.lower()
class IDMixin:
"""自增主键,全模型通用。"""
id: Mapped[int] = mapped_column(Identity(), primary_key=True)
class TimestampMixin:
"""创建/更新时间。"""
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), onupdate=func.now()
)
继承时多继承组合即可:
class Article(Base, IDMixin, TimestampMixin): # id + created_at + updated_at 全有了
title: Mapped[str] = mapped_column(String(200))
...
class Tag(Base, IDMixin): # 轻量表,只要 id
name: Mapped[str] = mapped_column(String(50), unique=True)
常见坑:
- Mixin 是普通类,不继承
Base——它是字段模板,不是表模型。只有最终组合出的子类才登记进档案柜。 - SQLAlchemy 2.0 允许把
mapped_column直接写在 Mixin 上,每个子类会自动拿到独立的列副本(不是共享同一个 Column 对象)。 - 需要按子类动态计算的东西(比如
__tablename__、引用别的类的 relationship)才需要declared_attr包一层——第 3 节的__tablename__就是这么写的。 TimestampMixin里的server_default=func.now()是数据库端默认值(DDL 里有DEFAULT CURRENT_TIMESTAMP),onupdate=func.now()是 ORM 更新时刷新——两者的边界在第 6 节讲过了。
13. PG 特有类型:以 JSONB 为代表
作用:PG 有一批"别人家没有"的类型,最常用的是 JSONB(二进制存储的 JSON,可索引、可用运算符查询)。存半结构化数据(文章的 meta 信息)时比普通 JSON 强一档。
最小示例:
from sqlalchemy.dialects.postgresql import JSONB, ARRAY
from sqlalchemy import DateTime, String
class Article(Base):
# PG 原生 JSONB
meta: Mapped[dict] = mapped_column(JSONB)
# 带时区的时间戳(PG 的 TIMESTAMPTZ)
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# PG 原生数组类型
keywords: Mapped[list[str]] = mapped_column(ARRAY(String(30)))
跨库兼容技巧——with_variant:demo 要在 SQLite 上跑,但生产用 PG,怎么办?让类型按方言变形:
# PG 上用 JSONB,其它库退化为通用 JSON
meta: Mapped[dict] = mapped_column(JSON().with_variant(JSONB(), "postgresql"))
意思是"默认按 JSON 处理,遇到 postgresql 方言就换成 JSONB"。一行代码,两边通吃。
常见坑:
JSON和JSONB的区别:JSON存文本原样(快进慢出),JSONB存解析后的二进制(稍慢进,可索引可查询)。生产基本无脑JSONB。- ORM 不追踪 JSON 内部变更:
article.meta["views"] += 1之后直接 commit,数据库可能纹丝不动。要么整体重新赋值article.meta = {...},要么用flag_modified(article, "meta")标脏。 - 这些类型从
sqlalchemy.dialects.postgresql导入,换数据库时记得同步改——除非用了上面的with_variant。
14. create_all:一键建表,但它不是迁移工具
作用:扫一遍 Base.metadata 档案柜里登记的所有表,不存在的建出来。开发期的效率神器。
签名:
Base.metadata.create_all(bind, checkfirst=True) # checkfirst=True:已存在的表跳过
异步姿势(第 04 章的引擎直接接上):
import asyncio
from model.base import Base
import model.article # noqa: F401 ← 只 import 不到位,表就进不了档案柜!
import model.tag # noqa: F401
from sqlalchemy.ext.asyncio import create_async_engine
async def init_db() -> None:
engine = create_async_engine("postgresql+asyncpg://user:pwd@localhost/db")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await engine.dispose()
asyncio.run(init_db())
run_sync 是异步引擎的固定用法:create_all 本身是同步 API,包在 run_sync 里让它在连接上安全执行。
它的定位,必须说清楚:
create_all是活动板房——快速搭起能用的表结构,适合开发、原型、测试环境。- 它只建新表,绝不改旧表:
checkfirst=True的意思是"存在就跳过",不是"对比差异后更新"。你给Article加了一列,create_all看一眼:表在,跳过。新列永远不会出现。 - 生产环境的表结构演进要靠 Alembic(第 07 章):它像一支带施工日志的正规施工队——每次改结构生成一个版本化的迁移脚本,升级、回滚、多环境同步都有据可查。
所以本章 demo 用 create_all 快速起跑,第 07 章马上换正规军。
常见坑:
- 最经典的坑:建了模型忘了 import,
create_all建出的库里就是少那几张表,而且不报错。 - 在异步引擎上直接
await Base.metadata.create_all(...)会失败——它是同步函数,必须走conn.run_sync(...)。 - 每次改了模型列结构,指望重跑
create_all生效?不会生效(见上)。开发期土办法是删库重建,正经办法是第 07 章的 Alembic。
15. 跑一下本章 demo
完整代码在 demo/demo_models.py,单文件自足:五个模型 + 建表 + 插入 + 回读验证。
uv add sqlalchemy aiosqlite # 装依赖(greenlet 会随 SQLAlchemy 自动装上)
python demo_models.py
实际运行输出(真实验证过):
[1] 建表完成: article, article_tag, article_version, channel, tag
[2] 插入完成: article.id=1(自增主键), channel=Channel(id=1, name='后端进阶'), tags=['SQLAlchemy', 'Python']
[3] 回读验证: 《SQLAlchemy 2.0 建模入门》
栏目 slug : backend(外键 channel_id=1)
标签 : ['SQLAlchemy', 'Python'](article_tag 关联表自动落行)
版本 : [(1, '(草稿)建模入门'), (2, 'SQLAlchemy 2.0 建模入门')]
创建时间 : 2026-08-27 15:16:52(server_default=func.now() 由数据库填充)
注意 demo 里两个值得回头看一眼的细节:
- 查询回读用了
selectinload预加载关系——异步会话里不能懒加载(第 9 节的坑); meta列用了JSON().with_variant(JSONB(), "postgresql")——SQLite 上跑 demo,PG 上生产,一套代码两用。
16. 小结速查
| 需求 | 用法 |
|---|---|
| 声明模型基类 | class Base(DeclarativeBase),一个项目一个 |
| 自动表名 | @declared_attr.directive 返回 cls.__name__.lower() |
| 定义列 | Mapped[类型] = mapped_column(...) |
| 可空列 | 注解写 X \| None |
| Python 端默认值 | default=值 / default=可调用 |
| 数据库端默认值 | server_default=func.now() |
| 唯一 / 索引 | unique=True / index=True,组合用 __table_args__ |
| 外键 | ForeignKey("表.列", ondelete="CASCADE") |
| 一对多 | 双向 relationship(back_populates=...) |
| 多对多 | Table(...) 复合主键关联表 + secondary= |
| 公共字段 | IDMixin / TimestampMixin 多继承 |
| 防循环导入 | if TYPE_CHECKING: + 字符串注解 |
| PG JSON | JSONB,跨库用 with_variant |
| 一键建表 | await conn.run_sync(Base.metadata.create_all) |
自测三问:
default和server_default各在什么环节生效?绕过 ORM 手写 INSERT 时,哪个还有效?- 多对多的关联表,什么时候用
Table+secondary,什么时候要升级成模型类? - 为什么说
create_all不能替代 Alembic?它对"已存在的表"做什么?
表已经建好,数据怎么进去、怎么查出来、怎么改——下一章把 SQL 表达式的十八般武艺过一遍。