SQL 表达式与数据操作
本章你将学会 - select() 骨架与链式拼装:where / orderby / limit / offset - 比较运算符全家桶:== 的真面目、like vs ilike、in、is(None) - …
本章你将学会
- select() 骨架与链式拼装:where / order_by / limit / offset
- 比较运算符全家桶:
==的真面目、like vs ilike、in_、is_(None)- and_ / or_ / not_ 组条件,
&|~的括号陷阱- func 聚合:count / sum / avg + group_by / having
- update().values()(含 synchronize_session)与 delete().where()
- join / outerjoin、外键一对多、多对多 any() / contains() 查询
- execute / scalars / scalar_one 的返回值差异
- 为什么 Core 表达式天然防 SQL 注入
第 04 章用 text() 手写 SQL、第 05 章把五张表立了起来,当时都留了同一句话:SQL 表达式第 06 章细讲。现在兑现。这一章是纯用法手册:每个 API 按「作用 → 签名 → 最小示例 → 常见坑」过一遍,学完你就能把数据库当 Excel 一样盘。
本章配套 demo(aiosqlite 上实测通过): -
demo/demo_select.py:查询全家桶——select / where / 排序分页 / 逻辑组合 / join / 关系条件 / 预加载 -demo/demo_write.py:写操作——update / delete / 聚合分组 / 外键级联示例业务统一用「墨集 inkhub 内容平台」:
Channel栏目、Article文章(channel_id 外键)、Tag标签、article_tag关联表、ArticleVersion版本(version_no / word_count / status)——第 05 章建好的那五张表,本章只管用。
1. 心智模型:先拼积木,再交翻译
先看同一件事的两种写法:
from sqlalchemy import select, text
# 写法一:text() —— SQL 已经是成品字符串,写歪了只有数据库知道
stmt = text("SELECT * FROM article WHERE channel_id = 1")
# 写法二:Core 表达式 —— 用 Python 对象拼 SQL,每一步都被检查
stmt = select(Article).where(Article.channel_id == 1)
打个比方:text() 是捏橡皮泥——一整块捏成形,裂了歪了没人提醒;Core 是搭乐高——列、运算符、函数全是标准件,凸点对凹点,装错位置根本插不进去。把拼好的作品打印出来看看:
print(select(Article.id, Article.title).where(Article.channel_id == 1))
# SELECT article.id, article.title
# FROM article
# WHERE article.channel_id = :channel_id_1
三个细节值得盯一眼:
Article.channel_id == 1不是在「比较」,是在「构造」——列的运算符被重载,返回一个 SQL 表达式对象;- 值 1 被自动装进绑定参数
:channel_id_1,和 SQL 骨架分道扬镳——这是第 13 节「天然防注入」的根基; - 语句只是 Python 对象:不执行、不发网络,可以打印检查、可以反复执行。
一句话记住本章的世界观:构建与执行分离。你负责拼乐高,引擎负责按数据库的方言翻译——同一份 limit(10),PG、SQLite、MySQL 各拿各的说明书去执行。
2. select():查询的骨架
作用:创建 SELECT 语句,决定「查什么」。
签名:select(*entities)——entities 可以是模型类、列、聚合表达式,一个或多个。
最小示例——三种典型查法:
select(Article) # 整个实体:配合 scalars() 拿模型对象
select(Article.id, Article.title) # 挑列查:拿到一行行的 Row
select(Channel.name, func.count(Article.id)) # 混入聚合列:第 7 节
链式拼装(每个方法返回语句的变形,随便往后接):
stmt = (
select(Article)
.where(Article.channel_id == 1) # 第 4 节
.order_by(Article.created_at.desc()) # 第 6 节
.limit(10)
)
常见坑
select(Article)和select(Article.title)查出来的东西类型不同:前者是模型对象流,后者是一行行的值——用混了取值方式,拿到的数据「能看不能摸」。第 3 节专门掰扯。- 多列 select 别接
.scalars():它只抽第一列,其余列全丢。 - 拼进 select 的模型必须已定义且被 import——第 05 章「档案柜」的规矩:没登记的图纸,这里直接 NameError。
3. 取结果:execute / scalars / scalar_one
第 04 章见过 execute + scalars() 的组合拳,这里把 Result 的家底一次摊开。先记这张层次图:
await session.execute(stmt) → Result 一行一个 Row(带列名的元组)
.scalars() → ScalarResult 只保留每行第一列
.scalar_one() → 第一行第一列,且行数必须恰好 1
最小示例——同一个查询的三种取法:
async with session_factory() as session:
# ① execute:拿 Result,迭代出一行行的 Row(支持元组解包)
result = await session.execute(
select(Article.id, Article.title).where(Article.channel_id == 1)
)
for id_, title in result:
print(id_, title)
# ② session.scalars:直接要「第一列」流——实体查询即模型对象
articles = (await session.scalars(select(Article))).all()
# ③ scalar_one:单值,必须恰好一行一列
title = (
await session.execute(select(Article.title).where(Article.id == 2))
).scalar_one()
拆快递的比喻:execute 收到一整箱,每层一个 Row 包装;scalars() 是开箱直取——每层直接抽出第一件;scalar_one() 最苛刻——必须恰好一箱一层一件,多一件少一件当场报警。
取值方法矩阵(Result 与 ScalarResult 同名方法,行为一致):
| 写法 | 返回 | 0 行时 | 典型场景 |
|---|---|---|---|
.all() |
列表 | [] |
列表页 |
.first() |
第一个或 None |
None |
「随便挑一条」 |
.one() |
恰好一个 | 抛 NoResultFound |
业务上必须存在 |
.one_or_none() |
一个或 None |
None |
查不到不算错 |
.scalar_one() |
单值 / 单对象 | 抛 NoResultFound |
主键查询、count |
.scalar_one_or_none() |
单值或 None |
None |
可空单值 |
常见坑
- 0 行、多行对
one()家族是异常而不是返回None:scalar_one()查出两行直接抛MultipleResultsFound——这不是它在找茬,是在帮你兜住「WHERE 条件不够唯一」的 bug。允许查不到的场景才用one_or_none()/first()。 - Result 是一次性流:
for循环跑完再.all(),得到的是空——结果要复用,第一次就存进变量。 conn.execute(stmt)和session.execute(stmt)返回的都是 Result;区别是会话版附带 ORM 的增值服务(scalars()、关系加载、autoflush)。
4. where():条件与比较运算符
作用:给语句挂 WHERE,逐行筛选。
签名:where(*条件)——塞多个参数就是 AND(第 5 节展开),每个参数是一个布尔表达式对象。
运算符对照表(左 SQL,右 Core 写法):
| SQL | Core 写法 | 备注 |
|---|---|---|
= |
Article.channel_id == 1 |
双等号! |
!= / <> |
Article.channel_id != 1 |
|
> >= < <= |
与 Python 一致 | |
LIKE |
Article.title.like("FastAPI%") |
% 任意串、_ 单字符 |
ILIKE |
Article.title.ilike("fastapi%") |
不区分大小写 |
IN |
Article.channel_id.in_([1, 3]) |
带下划线 |
IS NULL |
Article.summary.is_(None) |
|
IS NOT NULL |
Article.summary.is_not(None) |
|
BETWEEN |
Article.id.between(3, 6) |
双闭区间 |
头号坑:== 的真面目
Article.channel_id == 1 看着像比较,实际是构造——列对象重载了 __eq__,返回一个 SQL 表达式。两个直接后果:
# ① 单等号是赋值,Python 编译期直接拦(SyntaxError),这坑反而不常踩
select(Article).where(Article.channel_id = 1)
# ② 真正的坑:拿它当布尔判断——表达式对象永远 truthy
if Article.status == "draft": # 恒为 True!
...
写惯 Python 的人在第 ② 种翻车:判断「这篇文章的状态」应该用查出来的实例 article.status,而不是类属性表达式 Article.status == "draft"。前者是数据,后者是 SQL 零件——零件永远不为假。
like vs ilike:性格随数据库的一对兄弟
like:是否区分大小写看数据库心情。PG 区分;SQLite 的 LIKE 对 ASCII 不区分(中文不受影响)。同一行代码换个库行为就变——「本地好好的、上线查不对」的经典来源。ilike:明确不区分。PG 上生成原生ILIKE;SQLite 这类没有 ILIKE 的库,自动降级编译成lower(title) LIKE lower(?)。
结论:语义上要「确定不区分大小写」就写 ilike,别赌 like 的方言行为。
顺带认识 like 的快捷亲戚:contains("入门")、startswith("Fast")、endswith("指南")——自动帮你拼两边的 %,读起来也更像人话。
in_() 的坑:列表常来自用户勾选,可能是空的。in_([]) 会被编译成 1 != 1 式的恒假子句(SQLAlchemy 2.0 的安全行为),一行都匹配不到;但业务语义要想清楚——「一个都没勾」是查不到东西,还是干脆跳过这个条件?前者保留 in_,后者判断空列表后别拼这个 where。真正危险的是拿 text() 手拼 IN ({...}),那是往注入的老路上走。
is_(None) 的坑:Article.summary == None 其实能跑(会被编译成 IS NULL),但 == None 会被 flake8 按 E711 拦下——Python 惯例与 None 比较用 is,写成 == 也容易让读代码的人误会。统一 is_(None) / is_not(None),谁看了都不多想。
5. and_ / or_ / not_:条件的布尔运算
作用:把多个条件按与、或、非组合。
签名:and_(*expr)、or_(*expr)、not_(expr),可任意嵌套。
最小示例——「标题带 fastapi」或「深度长文且写了摘要」的文章:
from sqlalchemy import and_, not_, or_
stmt = select(Article).where(
or_(
Article.title.ilike("%fastapi%"),
and_(
Article.channel_id == 2, # 深度长文
not_(Article.summary.is_(None)), # 且写了摘要
),
)
)
where 的隐藏语法糖:多个参数就是 AND——
.where(Article.channel_id == 2, Article.summary.is_not(None))
# 等价于
.where(and_(Article.channel_id == 2, Article.summary.is_not(None)))
& | ~ 的括号陷阱
AND / OR / NOT 也能用位运算符写。但 Python 的位运算优先级高于比较运算符,所以:
# ❌ 想表达 (channel_id == 1) | (id == 2)
# 实际解析成 Article.channel_id == (1 | Article.id) == 2 ——当场 TypeError
.where(Article.channel_id == 1 | Article.id == 2)
# ✅ 用位运算符必须层层加括号
.where((Article.channel_id == 1) | (Article.id == 2))
记不住优先级就老实用 and_ / or_ / not_ 函数风格——永不歧义,本章 demo 全是这种写法。
6. order_by / limit / offset:排序与分页
作用:排序 + 截取,列表页接口的标配三件套。
签名:
stmt = (
select(Article)
.order_by(Article.created_at.desc(), Article.id) # ① 多键排序
.limit(10) # ② 最多取 10 行
.offset(20) # ③ 跳过前 20 行
)
asc()是默认方向,desc()倒序;多个键按书写顺序依次生效;- 分页公式:
offset = (page - 1) * per_page——第 3 页、每页 10 条,就是limit(10).offset(20); - 这俩是方言适配的代表案例:PG / SQLite / MySQL 用
LIMIT ... OFFSET ...,SQL Server 得换OFFSET FETCH——写 Core 的你不用管,引擎按方言翻译。
常见坑
- 不带 order_by 就分页 = 抽盲盒。SQL 标准压根没承诺无 ORDER BY 时的返回顺序,数据库今天一个心情明天一个心情——第 1 页和第 2 页可能重叠、可能漏行。
- 排序键不唯一 = 翻页会抖。两篇文章
created_at相同,谁排前面由数据库随手定——翻页时数据忽上忽下。药方:末尾补一个唯一列兜底,.order_by(Article.created_at.desc(), Article.id)。像运动会排名:成绩相同看编号,永远分得出高下。 - 深分页的性能账。
offset(100000)意味着数据库老老实实扫过并扔掉前 10 万行。后台系统翻到几万页越来越慢,元凶就是它——解法是游标分页(where(Article.id > 上一页最后一条).limit(20)),进阶话题,先记住症状和方向。
7. func:聚合与分组报表
作用:func 是 SQL 函数的万能转接台——func.函数名(参数) 能拼出任意函数调用,聚合(count / sum / avg / min / max)是它的头号业务。
最小示例——各栏目的文章数:
from sqlalchemy import func
stmt = (
select(Channel.name, func.count(Article.id).label("articles"))
.outerjoin(Article, Channel.id == Article.channel_id) # 空栏目也统计(第 8 节)
.group_by(Channel.name) # 按栏目分桶
)
async with session_factory() as session:
for name, cnt in await session.execute(stmt): # Row 也能直接解包
print(name, cnt)
label() 给聚合列起名:Row 按名字取值(row.articles),比 row[1] 可读一个量级。
再上一档:sum / avg 多级统计——用 05 章 ArticleVersion 的 word_count / status 正好:
stmt = (
select(
ArticleVersion.status,
func.count().label("versions"), # count(*) 数行数
func.sum(ArticleVersion.word_count).label("total"), # 总字数
func.avg(ArticleVersion.word_count).label("avg"), # 平均字数
)
.group_by(ArticleVersion.status)
)
having:给「组」加条件
stmt = stmt.having(func.count() >= 2) # 只保留版本数 ≥ 2 的状态
where 和 having 的分工,用招聘打比方:where 是初筛简历——不合格的人根本进不了统计;having 是按部门评优——先组队算完绩效,再淘汰整个部门。where 过滤行、发生在分组前;having 过滤组、发生在分组后——所以 having 里能用聚合值当条件,where 里不行。
常见坑
- select 了非聚合列却不 group_by 它:PG 直接报错(must appear in the GROUP BY clause);SQLite 宽松放行、随便挑一行凑数。于是「本地好好的、上生产炸了」。规矩:select 里每个非聚合列都写进 group_by。
- count(列) ≠ count(*):
count(Article.summary)不统计该列为 NULL 的行。数「没写摘要的文章」用count() - count(Article.summary);数总行数用count()(或主键列)。 - count 永远返回一行:空表时它返回一行、值是 0,而不是零行——判断「有没有数据」看值,别看
first() is None。 - avg 的返回类型看方言:SQLite 给 float;PG 对整数列求 avg 返回 Decimal。做除法或格式化前先
float(...)兜一道,两种环境行为才一致。
8. join / outerjoin:把两张表缝起来
作用:连接查询,让两表的行按条件两两配对。
签名:.join(target, onclause=None)——target 是模型或表;onclause 可省(ORM 能按外键 / relationship 自动推断)。.outerjoin() 是它的左外连接版。
最小示例——文章配栏目名:
# 写法一:显式 on 条件
stmt = (
select(Article.title, Channel.name)
.join(Channel, Article.channel_id == Channel.id)
)
# 写法二:交给 relationship 自动推断(05 章建过 Article.channel 就能省)
stmt = select(Article.title, Channel.name).join(Article.channel)
谁是左表?select 的第一个实体说了算:
select(Article.title, Channel.name).join(Channel, ...) # FROM article JOIN channel
select(Channel.name, Article.title).join(Article, ...) # FROM channel JOIN article
想另立主语用 select_from():select(Channel.name).select_from(Article).join(Article.channel)——FROM 从 article 起步。
outerjoin:左边的行一个都不丢
stmt = select(Channel.name, Article.title).outerjoin(
Article, Channel.id == Article.channel_id
)
# 没有文章的栏目照常出现,文章列补 NULL
内连接像拉链两边齿齿咬合——配不上的行直接消失;左外连接是只保左排的齿——右齿缺位用 NULL 填空。统计「含空栏目」的报表(第 7 节的按栏目计数)必须用它,否则空栏目凭空消失。
常见坑
- 两表的列都 select 了却忘了 join:生成笛卡尔积,行数 = 左行数 × 右行数。症状是结果暴涨、每条数据都似曾相识。看到重复组合,先查 join。
join(目标)省略 onclause 的前提是外键能唯一推断:两表间有多个外键或不搭界时会直接抛错——老老实实把 onclause 写上。- join 方向写反,查询逻辑就镜像了:
Article.channel与Channel.articles生成的是两条方向相反的 SQL——写之前想清楚「FROM 谁出发」。
9. 关系当条件:has() / any() / contains()
join 是手动挡;SQLAlchemy 还允许把 relationship 直接写进 where——join 自动补好。05 章建的关系,从这节开始回本。
口诀:单对象端用 has(),集合端用 any() / contains()。(拿单数属性 → has;拿复数属性 → any / contains)
# has():多对一端——「文章的栏目」满足条件(自动 JOIN channel)
select(Article).where(Article.channel.has(Channel.slug == "news"))
# any():集合端——「文章的标签」中存在满足条件的(自动 JOIN article_tag + tag)
select(Article).where(Article.tags.any(Tag.name == "Python"))
# contains():集合端,入参是具体对象——标签集合里含有这个 Tag
select(Article).where(Article.tags.contains(python_tag))
any() 吃条件表达式,contains() 吃已入库的对象——后两句查出来是同一批文章,只是入参姿势不同。反方向照样能用:
# 某篇文章都贴了哪些标签:Tag 的文章集合包含这篇文章
select(Tag).where(Tag.articles.contains(article_obj))
多对一端还有个语法糖——外键列直接比对象:
# 都翻译成 WHERE article.channel_id = :channel_id
select(Article).where(Article.channel == channel_obj)
select(Article).where(Article.channel_id == channel_obj.id)
常见坑
contains()的对象必须已持久化(有主键):传一个刚Tag(name="x")的内存对象,主键还是 None,条件无从定位行——先 add + flush 再用。has()/any()用反端会直接抛错。别死记,按语义想:has问的是「那一个对象满足吗」,any问的是「这堆里有没有满足的」。- 关系条件查出的实体,关系属性默认仍是未加载的——
article.tags想直接访问,还得靠下一节的预加载。
10. 预加载补课:selectinload / joinedload
第 05 章挖的坑这里填上:异步会话里访问懒加载关系会抛 MissingGreenlet——SQLAlchemy 想在你读属性那一刻悄悄发 SQL,而 asyncio 容不下这种「背着你做 IO」。
作用:.options() 声明预加载,把关系的加载并进你看得见的查询计划。
from sqlalchemy.orm import selectinload
stmt = select(Article).options(
selectinload(Article.channel), # 多对一
selectinload(Article.tags), # 多对多
)
articles = (await session.scalars(stmt)).all()
for a in articles:
print(a.channel.name, [t.name for t in a.tags]) # 已装好,随便读
两位主角的分工(经验法则):
| 加载器 | 机制 | 适合 |
|---|---|---|
selectinload |
主查询之后再发一条 IN (...) 查询 |
一对多 / 多对多集合 |
joinedload |
把 JOIN 并进同一条主查询 | 多对一单对象 |
常见坑
- 报错名先认下:
sqlalchemy.exc.MissingGreenlet——看到它,八成是忘了 options。注意 options 只装列出来的关系,访问没列的还是懒加载、还是炸。 - 分页 + 集合关系的组合,社区习惯用
selectinload:joinedload套集合时 SQLAlchemy 会自动包一层子查询来保证 LIMIT 语义正确(结果不会错),但 SQL 明显变绕;selectinload的两条直白 SQL 更好读也好调。
11. update().values():更新
作用:构造 UPDATE 语句,按条件批量改列值。
签名:update(实体).where(条件).values(列=值, ...)。
最小示例——连接级(Core 最纯粹的形态):
from sqlalchemy import update
async with engine.begin() as conn:
result = await conn.execute(
update(ArticleVersion)
.where(ArticleVersion.status == "review")
.values(status="published")
)
print(result.rowcount) # 实际改动了几行
rowcount 是 UPDATE / DELETE 的「战报」,写接口时直接当业务数字用。
values 的两种给法:
# ① Python 值:渲染成绑定参数 :status_1
.values(status="published")
# ② SQL 表达式:渲染成 article_version.word_count + 100,数据库端计算
.values(word_count=ArticleVersion.word_count + 100)
第 ② 种常被忽略——想「全部加 100」,不是查出所有行在 Python 里逐个加,而是把表达式交给数据库一条 SQL 搞定(一百万行也只有一条语句)。
synchronize_session:会话里的对象跟不跟着变
在会话里执行 Core update(await session.execute(update(...)))时有个绕不开的问题:数据库的行变了,会话里已经加载过的对象怎么办?这正是 synchronize_session 管的事:
| 取值 | 做法 | 代价 |
|---|---|---|
"auto"(默认) |
先试 "evaluate",评估不了退 "fetch" |
平衡 |
"evaluate" |
在 Python 里对内存对象求值 where,命中的直接改属性 | 最快;复杂条件评估不动 |
"fetch" |
更新前先 SELECT 命中行的主键,回内存逐个对账 | 多一次查询 |
False |
完全不管内存对象 | 最快;会话里的对象从此是旧闻 |
对比实验(demo_write.py 第 2 节实测):
async with session_factory.begin() as session:
version = (await session.scalars(select(ArticleVersion).where(...))).one()
await session.execute(
update(ArticleVersion).where(...).values(status="archived")
)
print(version.status) # 'archived'——默认 auto,内存对象已同步
await session.execute(
update(ArticleVersion)
.where(...)
.values(status="archived")
.execution_options(synchronize_session=False)
)
print(version.status) # 还是旧值——没人通知它
await session.refresh(version) # 要新值:手动对账
比喻:数据库是公告栏,会话里的对象是没看群的同事。公告栏换了通知(UPDATE),同事手里的旧通知单还在。synchronize_session 就是「改公告时顺手在群里 @所有人」;设成 False 就是「只换公告不吭声」——同事继续按旧通知干活,而且不报错。
常见坑
- 不带 where = 全表更新。SQLAlchemy 不会拦(它认为你是故意的),也没有安全开关——养成「先写 where 再写 values」的肌肉记忆。
synchronize_session=False后读旧值不报错,是最阴的坑:库里明明改了,日志打出来还是旧的。规则:关了同步就别信内存对象,要新值refresh()。values(title=user_input.upper())是 Python 先算好再当绑定参数传;想数据库执行转换,得写values(title=func.upper(Article.title))——中文场景未必用得上,英文数据清洗很顺手。
12. delete().where():删除
作用:构造 DELETE 语句,按条件批量删行。
签名:delete(实体).where(条件)。
最小示例:
from sqlalchemy import delete
async with engine.begin() as conn:
result = await conn.execute(
delete(ArticleVersion).where(ArticleVersion.status == "draft")
)
print(result.rowcount) # 删掉几行
rowcount == 0 就是「条件没删到东西」——REST 接口里标准的 404 语义,比抛异常再捕获干净:
result = await session.execute(delete(Article).where(Article.id == article_id))
if result.rowcount == 0:
... # 当 404 返回;统一成 HTTPException 是第 09 章的事
常见坑
- 和 update 一样:不带 where = 清空整张表,没有后悔药。
- Core delete 与两套级联(05 章知识的实战验收):
- ORM 级联
relationship(cascade="all, delete-orphan")只对session.delete(obj)生效——ORM 逐条发 DELETE; - 数据库级联
ForeignKey(ondelete="CASCADE")对 Core delete 和任何 SQL 都生效——前提是数据库真的执行外键(PG 默认执行;SQLite 要开PRAGMA foreign_keys=ON)。
换句话说:用 Core delete 删父行,ORM 级联根本不参与,级不级联全看数据库端的 ondelete。demo_write.py 第 5 节实测:删一篇文章,它的版本行被数据库的 ON DELETE CASCADE 连带清掉。
- 删掉的行若还躺在某个会话的内存里,和 update 同款问题——内存对象不知道自己没了。同样支持 synchronize_session(默认 auto 即可),或者删完别再碰旧对象。
13. SQL 注入的终点站:Core 为什么天然防
第 04 章演示过拼接 SQL 的惨案:攻击串 %' OR 1=1 -- 让全表裸奔。现在从机制上把话说透——Core 不是「更小心地防注入」,而是结构上就没给注入留门。
惨案与解法并排看:
keyword = "' OR 1=1 --"
# ❌ 拼接:数据混进了语句的「结构」——攻击串改变了 SQL 的语法意图
await conn.execute(text(f"SELECT * FROM article WHERE title LIKE '%{keyword}%'"))
# ✅ Core:值只走「参数」通道,结构一个字都没动
stmt = select(Article).where(Article.title.contains(keyword))
编译结果揭示了原理:
print(select(Article).where(Article.channel_id == 1))
# SELECT ... FROM article WHERE article.channel_id = :channel_id_1
你给的值,在构建表达式的那一刻就被装进绑定参数——SQL 骨架(结构)和参数值(数据)从出生起就是两条通道,分开递给数据库。数据库先解析骨架,再把参数当纯文本填进坑里——填什么,都改变不了骨架的形状。攻击串里的引号、OR、注释符,到这里全成了普通字符。
对比一下 text():它的安全靠自觉(记得写 :占位符,管住 f-string 的手);Core 的安全靠构造——Article.title == kw 这种写法里,kw 想混进结构都没有入口。想不安全都难,这才叫「天然防」。
最后一个角落:LIKE 的通配符
防注入防的是「数据变成指令」;但 % 和 _ 在 LIKE 里本来就是指令(通配符)。用户在搜索框输入 %,不会被当 SQL 执行,却会让 LIKE 匹配所有行——结构没泄,全量数据泄了,算半个注入:
# autoescape=True:自动转义用户输入里的 % 和 _
# 生成 LIKE '%' || :param || '%' ESCAPE '/'
select(Article).where(Article.title.contains(keyword, autoescape=True))
做搜索框的同学记这一条就够:contains(kw, autoescape=True)。like / ilike / startswith / endswith 全家族都支持 autoescape。
14. 本章速查表
| 需求 | 写法 |
|---|---|
| 查全表 | select(Article) + scalars() |
| 挑列查 | select(Article.id, Article.title) → Row |
| 单条必存在 | .scalar_one() / .one();查不到要 None 用 one_or_none() |
| 等值 / 区间 | ==、!=、between(a, b) |
| 模糊 | like / ilike;搜索框用 contains(kw, autoescape=True) |
| 集合 | .in_([...])(空列表 = 恒假) |
| 判空 | .is_(None) / .is_not(None) |
| 与或非 | and_ / or_ / not_(或括号齐全的 & \| ~) |
| 排序分页 | .order_by(X.desc(), Article.id).limit(n).offset(m) |
| 聚合分组 | func.count / sum / avg + group_by + having |
| 连接 | .join(目标, on条件);保左表用 .outerjoin() |
| 关系条件 | 单对象端 has();集合端 any(条件) / contains(对象) |
| 预加载 | options(selectinload(集合), joinedload(单对象)) |
| 更新 | update(实体).where(...).values(...);rowcount 看战果 |
| 会话同步 | synchronize_session:auto / evaluate / fetch / False |
| 删除 | delete(实体).where(...);rowcount == 0 → 404 语义 |
| 防注入 | 值自动走绑定参数;搜索框加 autoescape=True |
自测三问
execute()、session.scalars()、scalar_one()各拿到什么?后两个在 0 行、多行时分别发生什么?where和having各在什么阶段、对什么对象生效?为什么 having 里能拿count()当条件而 where 里不能?- 会话里用
synchronize_session=False执行 update 后,内存对象读到的是什么?两种补救姿势分别是什么?
到这里,「查、改、删」三板斧凑齐,SQL 表达式的用法手册翻完。剩最后一块拼图:表结构本身要演进——加列、改约束、回滚错误决策,create_all 指望不上(05 章的结论),得请带施工日志的正规军 Alembic 了。