搜索不过是给列表页加一个过滤条件:join 到标签表,ilike 加两个百分号。 distinct 不能省,一张图命中两个标签就会在页面上出现两行。 % 和 _ 是 LIKE 的通配符,用户输一个百分号就能让全表出来。
这一篇对应教材 9.1 到 9.3:建立图片搜索功能的端点、模板,以及确认运作情况。 第 8 章做完检测,库里有标签了,这一篇让标签能被搜出来。
一、端点的四条要点
q = request.args.get("q", "").strip()
rows = db.session.execute(
db.select(UserImage)
.join(UserImageTags)
.where(UserImageTags.tag.ilike("%" + q + "%"))
.order_by(UserImage.id).distinct()
).scalars().all()
五行,四个要点:取值去空格、join 到标签表、两个百分号包起来、distinct 去重。
distinct 不能省。一张图有两个标签都命中时,join 会把它返回两次,页面上就出现两行一样的图。
用户看到的第一反应是「数据库坏了」。
ilike 是 PostgreSQL 的写法,SQLite 里也能用。
差别在底层:SQLite 的 LIKE 本来就不区分大小写,但只对 ASCII 有效;
换成 PostgreSQL 之后,大小写的行为才真的由 ilike 决定。
返回值用 .scalars().all() 拿模型对象,而不是 .all() 拿 Row 元组。
模板里直接写 r.filename、r.tags 都行。
二、搜不到是 200,不是 404
| 关键字 | 命中 |
|---|---|
dog |
2 张 |
cat |
1 张 |
bird |
0 张 |
三种情况返回的都是 200。搜不到的时候页面上多一句「没有找到带这个标记的图片」。 搜索无结果是一种正常状态,不是错误。返回 404 的话,浏览器会跳到错误页, 用户搜个没见过的词就被判定「页面不存在」,体验很怪。 这也解释了为什么搜索页跟错误页要分开:一个说「没有」,一个说「不对」。
三、大小写与部分匹配
| 写法 | 命中 | 为什么 |
|---|---|---|
DOG |
2 张 | 跟搜 dog 一样,ASCII 不区分大小写 |
Person |
2 张 | 数据里存 Person,小写的 person 也命中 |
do |
2 张 | 两边带 %,是「包含」不是「等于」 |
ar |
1 张 | 命中的是 car 中间那两个字母 |
ar 这条最能说明「包含」的意思:car 里既不以 ar 开头也不以 ar 结尾,照样命中。
顺手记住一件事:前面带 % 的 LIKE 用不上索引。
数据库的索引是按前缀排的,你告诉它「末尾是什么我不知道」,它只能一行一行看。
表小的时候没感觉,几百万行就是几秒钟。
真做搜索得上 FTS(SQLite 有 FTS5,PostgreSQL 有 tsvector),或者干脆交给专门的搜索引擎。 这是另一个话题,但要知道这里是个天花板。
四、坑一:百分号是通配符
| 输入 | 命中 |
|---|---|
% |
4 张(全中) |
_ |
4 张(全中) |
库里 5 张图,4 张有标签,所以「全中」是 4。
这不是 bug,是 LIKE 的语义:% 匹配任意长度的任意字符,_ 匹配单个字符。
用户输一个百分号,拼出来就是 like '%%%',等于什么都匹配。
要按字面搜,得先转义再声明转义字符:
q = q.replace("%", "\\%").replace("_", "\\_")
where(UserImageTags.tag.ilike("%" + q + "%", escape="\\"))
大多数搜索框都忘了这一步。不转义不会报错、不会崩溃,只是搜一个百分号会出来全部, 没人会去搜百分号,所以永远发现不了。
五、坑二:拼接字符串就是注入
把 ORM 换成拼字符串,同一个搜索词,结果完全不同:
| 写法 | 搜 zz' or 1=1 -- |
命中 |
|---|---|---|
| 拼字符串 | like '%zz' or 1=1 --%' |
4 张 |
| ORM 参数化 | 值原样当参数 | 0 张 |
拼出来的那句 SQL 里,-- 把后面那截注释掉了,剩下 like '%zz' or 1=1。后半段恒真,全表都出来了。
这个例子里只是多读了几行。换成 drop table 或者 union select 就是另一种后果了。
参数化不是「更安全」,是「代码和数据在物理上分开了」。走参数化之后,
那个引号只是值里的一个字符,它没有机会变成 SQL 的语法。
这一条跟第 5 章那个「f-string 拼 SQL」是同一个东西,只是那次是查询条件, 这次是用户输入——后者才是真的有人会去试。
六、坑三:空字符串
实测搜空串、搜三个空格,都返回 0 张。
靠的是两处:request.args.get("q", "") 给个默认空串,if q: 把空串挡在外面。
少了 if q: 那一句,"%" + "" + "%" 就是 %%,匹配所有标签。
搜索页一打开就列出全部图片,跟列表页没区别。
strip() 也不能少:用户复制粘贴常带前后空格,不去掉的话搜 "dog " 就是 %dog %,
尾巴那个空格让它在很多情况下搜不到。
七、模板三件事
<h2>「{{ q }}」的搜索结果({{ rows|length }} 张)</h2>
{% if not rows %}<p>没有找到带这个标记的图片。</p>{% endif %}
第一,把关键字显示在标题里。
用户能确认自己搜的是什么,而不是对着一个空结果猜「我刚才打的是 dog 还是 dogs」。
第二,空结果给一句话。空白页会让人以为页面坏了。
第三,不用 |safe。{{ q }} 默认转义,用户输 <script> 只会显示成字面量;
加了 |safe 就真执行了——那是自己给自己开的洞。
结果行里显示的是这张图的全部标签,不是命中的那一个。 因为一张图命中,用户想看的是「它整体被标了什么」。
八、这一篇的位置
搜索是第 2 篇的最后一件事。到这里,这个物件侦测应用该有的功能都有了:上传、检测、看结果、搜、删。 接下来两章都是「收尾」性质的:第 10 章管出错的时候长什么样,第 11 章管怎么证明它没坏。 功能做得再多,出错页是一屏英文堆栈,用户照样不敢用。
九、动手 Demo:搜索:一条 LIKE 背后的三个坑
这个 Demo 造 5 张图 6 条标签,先把正常的搜索跑一遍,再把三个坑挨个踩下去。 第五节那个百分号、第六节那次真注入,是这一篇最该带走的两件事。
@bp.route("/search")
@login_required
def search():
q = request.args.get("q", "").strip()
rows = []
if q:
rows = db.session.execute(
db.select(UserImage)
.join(UserImageTags)
.where(UserImageTags.tag.ilike("%" + q + "%"))
.order_by(UserImage.id).distinct()
).scalars().all()
return render_template("images/search.html", rows=rows, q=q)
实跑输出:
--------------------------------------------------------------------
搜索:一条 LIKE 背后的三个坑
--------------------------------------------------------------------
造了 5 张图、6 条标签
① 端点长什么样
--------------------------------------------------------------------
[端点核心]
| q = request.args.get("q", "").strip()
| rows = db.session.execute(
| db.select(UserImage)
| .join(UserImageTags)
| .where(UserImageTags.tag.ilike("%" + q + "%"))
| .order_by(UserImage.id).distinct()
| ).scalars().all()
join 到标签表,ilike 做不区分大小写的模糊匹配,distinct 去重。
! distinct 不能省:一张图有两个标签都命中时,join 会把它返回两次,页面上就出现两行一样的图。
! ilike 是 PostgreSQL 的写法,SQLite 里也能用。
差别在底层:SQLite 的 LIKE 本来就不区分大小写,但只对 ASCII 有效,中文没有大小写问题。
换成 PostgreSQL 之后,大小写的行为才真的由 ilike 决定。
② 正常搜一次
--------------------------------------------------------------------
搜 dog -> 200,命中 2 张
搜 cat -> 200,命中 1 张
搜 bird -> 200,命中 0 张
! 搜不到返回的是 200 加一句「没有找到」,不是 404。搜索无结果是一种正常状态,不是错误。
③ 大小写
--------------------------------------------------------------------
搜 DOG -> 命中 2 张(跟搜 dog 一样)
搜 Person -> 命中 2 张;搜 person -> 命中 2 张
! 数据里存的是 Person(大写 P),搜小写也命中。SQLite 的 LIKE 对 ASCII 不区分大小写。
④ 只输一部分
--------------------------------------------------------------------
搜 do -> 命中 2 张
搜 ar -> 命中 1 张
! 两边都加了 %,所以是「包含」而不是「等于」。搜 ar 能命中 car。
! 这也是它慢的原因:前面带 % 的 LIKE 用不上索引,表大起来只能全表扫。
真要做搜索,得上 FTS 或者专门的搜索引擎。
⑤ 坑一:% 和 _ 是通配符
--------------------------------------------------------------------
搜一个百分号 -> 命中 4 张(全中)
搜一个下划线 -> 命中 4 张
! 用户输一个 %,等于让 SQL 匹配所有东西。
这不是 bug,是 LIKE 的语义:% 匹配任意长度,_ 匹配单个字符。
! 要按字面搜,得转义:q.replace("%", "\%").replace("_", "\_"),再在 SQL 后面加 ESCAPE '\'。
大多数搜索框都忘了这一步。
⑥ 坑二:拼接字符串就是注入
--------------------------------------------------------------------
拼字符串的版本,搜 "zz' or 1=1 -- "
-> 200,命中 4 张(本该 0 张)
ORM 那版同一个输入 -> 命中 0 张
! 拼出来的 SQL 是 where tag like '%zz' or 1=1 --%'。
-- 把后面那截注释掉了,剩下 like '%zz' or 1=1——后半段恒真,全表都出来了。
! 这不是「更安全」,是「代码和数据在物理上分开了」:走参数化之后,那个引号只是值里的一个字符,
进不了 SQL 的语法。
⑦ 坑三:搜空字符串
--------------------------------------------------------------------
搜空串 -> 200,命中 0 张
搜三个空格 -> 200,命中 0 张
! 都返回 0 张。
端点里 if q: 那句把空串挡在外面了——不然 % 会匹配所有标签,搜索页一打开就列出全部图片,
跟列表页没区别。
! strip() 也是必须的:用户复制粘贴常带前后空格,不去掉就搜不到。
⑧ 模板
--------------------------------------------------------------------
[代码原样]
| <h2>「{{ q }}」的搜索结果({{ rows|length }} 张)</h2>
| {% if not rows %}<p>没有找到带这个标记的图片。</p>{% endif %}
把用户输的关键字原样显示在标题里,能让人确认自己搜的是什么。
! {{ q }} 默认会被转义,所以输 <script> 只会显示成字面量。
用 |safe 才会真的执行——那是自己给自己开洞。
! 结果里显示的是这张图的所有标签,不是命中的那个。
因为一条图片命中,用户想看的是它整体被标了什么。
第五节那两个 4 张:库里有 5 张图,4 张带标签,所以「全中」是 4。
第六节两个数必须并排看:拼字符串 4 张、ORM 0 张,同一个输入。
第七节的 0 张是 if q: 挡下来的,不是搜不到。
30 秒实验
- 把
.distinct()删掉,给一张图打两个都含 do 的标签,再搜 do。 - 把
if q:改成if True:,打开一次搜索页看结果。 - 想一想:为什么前面带 % 的 LIKE 用不上索引?
常见误区
- 不加 distinct:一张图命中两个标签就出现两行。
- 搜不到就返回 404:搜索无结果是正常状态,不是页面不存在。
- 不转义 % 和 _:用户输一个百分号就全表出来。
- 用 f-string 拼搜索条件:这次的输入真的来自用户。
自测三问(点开对答案)
1. 为什么 join 之后要 distinct? —— 一张图有多个标签命中时会被返回多次,页面上重复显示。 2. 搜一个百分号为什么会全中? —— % 是 LIKE 的通配符,匹配任意长度,得先转义再声明 escape 字符。 3. 参数化为什么能防注入? —— 代码和数据分开了,值里的引号只是字符,进不了 SQL 语法。第 9 章到此结束。下一篇做出错页:404 的时候用户看到什么。
- ☐ (本篇完,下一篇 10.1 自定义错误页面:处理器与模板)