开始前,会这些就够了
- 能读懂 SELECT、WHERE 与简单 Python 函数。
- Python 3.10 或更新版本,包含标准库 sqlite3。
- 知道用户输入应通过占位符传给 SQL,无需安装数据库服务。
商品标签里有“20%折扣”,你在搜索框输入“20%”,却连“200折扣”和“20_折扣”也找到了。检查代码后发现,SQL 明明用了问号占位符,没有拼接用户输入。问题并不一定是绑定失败:输入进入了另一个会解释特殊符号的地方。
本篇用六个虚构标签搭一个小搜索器,比较“用户填写模式”和“用户填写要找的文字”两种接口。产物是一份十行搜索矩阵,保留原输入、真正传入的参数和命中的编号,不依赖截图猜测发生了什么。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Python 3.14.0。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- like_literal.py:完整示例
- README.md:环境、范围与独立练习
- observed.stdout.txt:制作时的实际输出
- verification.json:源码与产物验证记录
- SHA256SUMS.txt:文件内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B like_literal.py解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。真实临时 SQLite 数据库中的参数查询与 LIKE 模式;不代表其他数据库、中文分词或搜索性能。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
一句输入,可能经过两套不同规则
先看查询的固定形状:WHERE name LIKE ?。问号代表一个值的位置,绑定让后面的输入作为这个值交给数据库。假如输入含单引号,它仍属于参数里的文字,不会因此成为 SQL 语句的新片段。表名、排序方向等结构也不能指望靠这个值占位符任意替换。
但 LIKE 的右侧本来就是一个模式。数据库拿到参数后,还会按模式规则判断哪些名称符合它。参数绑定负责交接值,并没有承诺把这个值里所有符号都改成普通文字。理解这两层,才能避免“用了问号,所以搜索含义一定正确”的推断。
我们的两种搜索都使用占位符,没有故意运行拼接 SQL 的危险分支。对比的变量只有模式怎样构造。因此观察到命中变多时,应该检查模式含义,而不是把本实验写成已经成功实施了一次 SQL 注入。
先用四个相似标签,看清百分号和下划线
编号一到四分别是“20%折扣”“200折扣”“20_折扣”“20!折扣”。原始模式分支把输入两端加上百分号,表示前后可以有其他内容。输入 20% 时,最终参数是 %20%%,中间那个百分号也成为通配符,所以四条都匹配。
输入 20_ 时,最终参数是 %20_%。下划线表示一个字符,因此百分号、数字零、下划线和感叹号都能填这个位置。这里的“一个字符”不是“一个字节”;本例也没有进一步把它承诺成人眼所见的一个完整字形。
如果产品确实提供高级模式搜索,这种行为可以是功能。但普通名称搜索往往希望用户输入什么就找什么。先写清接口承诺,再决定是否保留通配符,不能在修复时悄悄改变已经对用户开放的搜索语言。
指定一个转义符,再把用户部分按文字处理
本例给 LIKE 增加 ESCAPE '!',约定感叹号用于说明后一个特殊符号应按文字理解。literal_pattern 先把输入中的 ! 变成 !!,再处理百分号和下划线,最后才加上用于包含搜索的首尾百分号。这样用户自己的符号与程序添加的符号承担不同职责。
顺序很关键。如果先给百分号加感叹号,最后又统一把感叹号翻倍,刚刚加入的标记也可能被改变。输入 20! 的对照专门检查转义符本身,避免只测百分号通过,就误以为所有输入已经覆盖。参数仍然通过问号传入,转义函数没有替代绑定。
转义符在 SQLite 中必须是一个字符;脚本另用两个感叹号作为 ESCAPE 值,确认它会被拒绝。这是 SQL 表达式的约束,与搜索词里面允许有多少个感叹号不同。前者固定在程序里,后者由转换函数逐个处理。
十行记录要同时保留输入、参数和结果
五个输入各走两次查询,共得到十条记录。除了前面的三个符号案例,还加入 O'Reilly 和一段看起来像 SQL 条件的文字。它们都留在绑定值中;后者没有命中任何标签,标签总数仍为六,说明这份固定样本没有因为文字外观改变语句结构。
别只核对结果条数。四条变成一条,可能因为程序漏掉了全部有效名称,碰巧只留下一条。脚本按编号排序并断言完整编号列表,CSV 中也保留真正绑定的参数,读者可以把每一个百分号追溯到用户输入或程序添加。
这些样本有意很小,方便你逐条检查。它们验证本函数和当前 SQLite 环境下的语义,没有覆盖所有数据库方言、驱动设置和排序规则,更没有证明整个应用不存在其他 SQL 构造问题。
运行完整脚本,留下可重新检查的搜索账本
把下方完整代码保存为 like_literal.py,或从练习包解压得到同名文件,在该目录运行。程序只创建新的内存数据库,退出时关闭连接;不读已有数据库文件。脚本旁的 like-output 目录会留下 report.json 和 matches.csv,同名教学结果在下次运行时更新。
先预测 20% 和 20_ 的两组编号,再运行并核对。制作时使用 Python 3.14.0、SQLite 3.50.4,十条查询对照通过,末行出现 PASS。版本号也写进 JSON,你换环境后应保留自己的记录,而不是把制作时的成功当成当地执行成功。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""Actual SQLite LIKE comparisons; only a fresh in-memory database is used."""
from contextlib import closing
from pathlib import Path
import csv
import json
import sqlite3
import sys
def literal_pattern(term):
if not isinstance(term, str) or not term or "\x00" in term:
raise ValueError("use a non-empty text term without NUL")
# Escape the escape character first, then the pattern metacharacters.
return "%" + term.replace("!", "!!").replace("%", "!%").replace("_", "!_") + "%"
def main():
records = [(1, "20%折扣"), (2, "200折扣"), (3, "20_折扣"),
(4, "20!折扣"), (5, "O'Reilly"), (6, "ordinary")]
expected = {"20%": ([1, 2, 3, 4], [1]), "20_": ([1, 2, 3, 4], [3]),
"20!": ([4], [4]), "O'Reilly": ([5], [5]),
"' OR 1=1 --": ([], [])}
rows = []
with closing(sqlite3.connect(":memory:")) as db:
db.execute("CREATE TABLE label(id INTEGER PRIMARY KEY, name TEXT NOT NULL)")
db.executemany("INSERT INTO label VALUES (?, ?)", records)
for term, (raw_ids, literal_ids) in expected.items():
for mode, pattern, sql, wanted in [
("bound_pattern", "%" + term + "%", "SELECT id FROM label WHERE name LIKE ? ORDER BY id", raw_ids),
("bound_literal", literal_pattern(term), "SELECT id FROM label WHERE name LIKE ? ESCAPE '!' ORDER BY id", literal_ids),
]:
ids = [r[0] for r in db.execute(sql, (pattern,))]
assert ids == wanted, (term, mode, ids)
rows.append({"term": term, "mode": mode, "bound_value": pattern, "matched_ids": ids})
assert db.execute("SELECT COUNT(*) FROM label").fetchone()[0] == 6
case_rules = db.execute("SELECT 'a' LIKE 'A', 'æ' LIKE 'Æ'").fetchone()
assert case_rules == (1, 0), "this lesson expects SQLite's built-in LIKE without ICU/overrides"
try:
db.execute("SELECT 'x' LIKE ? ESCAPE '!!'", ("x",)).fetchone()
except sqlite3.OperationalError:
invalid_escape_rejected = True
else:
raise AssertionError("two-character ESCAPE accepted")
rejected = []
for term in ["", "a\x00b", None]:
try:
literal_pattern(term)
except ValueError:
rejected.append(repr(term))
else:
raise AssertionError("invalid search term accepted")
report = {"python": sys.version.split()[0], "sqlite": sqlite3.sqlite_version,
"scope": "real SQLite, built-in LIKE, in-memory fixture; no performance test",
"records": records, "comparisons": rows,
"ascii_case_match": bool(case_rules[0]), "non_ascii_case_match": bool(case_rules[1]),
"two_character_escape_rejected": invalid_escape_rejected,
"rejected_terms": rejected}
out = Path(__file__).resolve().parent / "like-output"
out.mkdir(exist_ok=True)
(out / "report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
with (out / "matches.csv").open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.DictWriter(file, fieldnames=["term", "mode", "bound_value", "matched_ids"])
writer.writeheader()
writer.writerows({**r, "matched_ids": json.dumps(r["matched_ids"])} for r in rows)
print(json.dumps(report, ensure_ascii=False, indent=2))
print("PASS: 10 bound-query comparisons, escape validation and explicit input policy")
if __name__ == "__main__":
main()
怎样运行:python -B like_literal.py(Python 3.10+,仅标准库;在解压后的脚本目录执行。) 2026-10-15 已用 Python 3.14.0 执行这份代码并通过自带检查;下方是当次输出。真实临时 SQLite 数据库中的参数查询与 LIKE 模式;不代表其他数据库、中文分词或搜索性能。
运行后对照结果
{
"python": "3.14.0",
"sqlite": "3.50.4",
"scope": "real SQLite, built-in LIKE, in-memory fixture; no performance test",
"records": [
[
1,
"20%折扣"
],
[
2,
"200折扣"
],
[
3,
"20_折扣"
],
[
4,
"20!折扣"
],
[
5,
"O'Reilly"
],
[
6,
"ordinary"
]
],
"comparisons": [
{
"term": "20%",
"mode": "bound_pattern",
"bound_value": "%20%%",
"matched_ids": [
1,
2,
3,
4
]
},
{
"term": "20%",
"mode": "bound_literal",
"bound_value": "%20!%%",
"matched_ids": [
1
]
},
{
"term": "20_",
"mode": "bound_pattern",
"bound_value": "%20_%",
"matched_ids": [
1,
2,
3,
4
]
},
{
"term": "20_",
"mode": "bound_literal",
"bound_value": "%20!_%",
"matched_ids": [
3
]
},
{
"term": "20!",
"mode": "bound_pattern",
"bound_value": "%20!%",
"matched_ids": [
4
]
},
{
"term": "20!",
"mode": "bound_literal",
"bound_value": "%20!!%",
"matched_ids": [
4
]
},
{
"term": "O'Reilly",
"mode": "bound_pattern",
"bound_value": "%O'Reilly%",
"matched_ids": [
5
]
},
{
"term": "O'Reilly",
"mode": "bound_literal",
"bound_value": "%O'Reilly%",
"matched_ids": [
5
]
},
{
"term": "' OR 1=1 --",
"mode": "bound_pattern",
"bound_value": "%' OR 1=1 --%",
"matched_ids": []
},
{
"term": "' OR 1=1 --",
"mode": "bound_literal",
"bound_value": "%' OR 1=1 --%",
"matched_ids": []
}
],
"ascii_case_match": true,
"non_ascii_case_match": false,
"two_character_escape_rejected": true,
"rejected_terms": [
"''",
"'a\\x00b'",
"None"
]
}
PASS: 10 bound-query comparisons, escape validation and explicit input policy按字面搜索,仍然需要约定大小写和空输入
本例采用 SQLite 内置 LIKE,没有加载 ICU 扩展或覆盖同名函数。它同时执行 a 与 A、æ 与 Æ 两个对照,得到前者匹配、后者不匹配。转义了通配符,并不会自动获得面向所有语言的大小写折叠,更不等于统一了所有看起来相似的文字。
函数另行拒绝空文字、含 NUL 的文字和非字符串输入。这是本小应用主动选择的输入政策:空搜索不自动展示全部标签,避免读者误以为任何空输入都应该变成两个百分号。它不是 SQLite 的通用限制,也不能直接替业务决定空搜索的用户体验。
如果需要区分大小写、支持全角半角转换或按语言分词,应另定规则并增加相应样本。不要把这些要求偷偷塞进名为 escape 的函数,让调用方无法判断它到底改变了哪些字符和搜索含义。
带回项目时,先固定搜索承诺再考虑索引
检查实际代码时,可以沿三步走:SQL 结构是否固定,输入作为哪个参数传入,这个参数又被 LIKE、正则或别的语言怎样解释。只做第一步检查,会漏掉搜索规则的差异;只做符号转义,又可能忘掉输入仍应绑定。
本实验在模式两侧加百分号,专注于包含搜索的正确性,没有比较索引计划或查询耗时。生产搜索需要多少条、怎样排序、能否接受全表扫描,都要用相应数据库与数据规模另测。一个十行正确性矩阵不能当作性能报告。
最后让维护者能从一条失败查询还原原始搜索词、构造后的模式和实际命中项。不要在真实日志中无条件收集私人搜索内容;这里能完整公开,是因为六条标签和五个输入全部为教学虚构数据。
记住这三点
- 参数绑定固定值与 SQL 结构的边界,LIKE 仍会解释参数里的模式符号。
- 字面包含搜索需要同时处理转义符、百分号和下划线,再添加程序自己的通配符。
- 按完整命中编号核对结果,并把大小写、空输入与性能要求分别定义。
别把例子用过头
- 把 LIKE 命中变多直接当成 SQL 结构注入。
- 先处理百分号,再把自己加的转义符一并改坏。
- 认为转义后所有语言的大小写行为都一致。
自己试一小步
为一组新的文件标签设计字面搜索,怎样证明输入中的符号没有扩大匹配范围?
- 自拟至少八个标签,包含连续感叹号、多个百分号、下划线和混合大小写,不沿用本文标签。
- 先写出四个搜索词在两种模式下的完整预计编号列表。
- 修改脚本副本运行,保留原输入、绑定值和实际结果,逐项比较预测。
- 单独说明空输入的产品政策,并新增一个会触发它的检查。
做到这些,就算完成
- 没有把预期答案直接写进搜索函数。
- 每一条命中都有对应标签,不能只交条数。
- 能区分 SQL 绑定、LIKE 模式和产品输入政策三种规则。
- 没有把这次语义验证扩写为索引或吞吐量已经验证。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- SQLite:表达式中的 LIKE 与 ESCAPE (新标签页)↗
核读第 4、5 节:参数位置、百分号和下划线含义、单字符 ESCAPE,以及默认 ASCII 与非 ASCII 大小写差异。
- Python 3.14:sqlite3 的参数占位符 (新标签页)↗
核读占位符指南、execute 与连接上下文管理器说明,确认值通过参数交接,以及连接需要明确关闭。
- SQLite:向预备语句绑定值 (新标签页)↗
核读 host parameters 与 bind 接口说明,支持参数为预备语句的值位置;不把该接口说成 LIKE 转义器。