SQLite UPDATE OF 触发器:列名拼错了,为什么创建成功却一直不响
给库存表增加一个数量变化日志,迁移脚本显示 CREATE TRIGGER 成功,更新后却始终没有日志。反复检查提交和连接仍找不到原因,最后发现 UPDATE OF 后面的 quantity 少了一个字母。SQLite 对这个位置的未知列名有历史兼容行为:它会忽略不认识的名称,而不一定拒绝创建。
因此,建库成功与触发行为正确是两次不同验收。下面在内存数据库建立物品表和日志表,先保留拼错的触发器,再创建正确版本,并用断言观察日志内容。保存为 demo.py 运行,不会接触现有业务数据库。
先证明对象存在,再证明它没有触发
AI概念示意图:形状不匹配的订阅键没有接通铃铛,正确键才能触发动作。图片不是运行截图。
import sqlite3
con = sqlite3.connect(":memory:")
try:
con.executescript("""
CREATE TABLE item(id INTEGER PRIMARY KEY, quantity INTEGER, note TEXT);
CREATE TABLE audit(kind TEXT);
INSERT INTO item VALUES(1, 1, 'a');
CREATE TRIGGER typo AFTER UPDATE OF quantitty ON item
BEGIN INSERT INTO audit VALUES('typo'); END;
""")
assert con.execute("SELECT name FROM sqlite_schema WHERE type='trigger'").fetchone() == ("typo",)
con.execute("UPDATE item SET quantity=2 WHERE id=1")
assert con.execute("SELECT count(*) FROM audit").fetchone()[0] == 0
print("typo exists, audit rows:", 0)
con.executescript("""
CREATE TRIGGER watched AFTER UPDATE OF quantity ON item
BEGIN INSERT INTO audit VALUES('watch'); END;
""")
con.execute("UPDATE item SET quantity=quantity WHERE id=1")
assert con.execute("SELECT kind FROM audit").fetchall() == [("watch",)]
con.executescript("""
CREATE TRIGGER changed AFTER UPDATE OF quantity ON item
WHEN OLD.quantity IS NOT NEW.quantity
BEGIN INSERT INTO audit VALUES('change'); END;
""")
con.execute("UPDATE item SET quantity=2 WHERE id=1")
before = con.execute("SELECT count(*) FROM audit").fetchone()[0]
con.execute("UPDATE item SET note='b' WHERE id=1")
assert con.execute("SELECT count(*) FROM audit").fetchone()[0] == before == 2
con.execute("UPDATE item SET quantity=NULL WHERE id=1")
values = [r[0] for r in con.execute("SELECT kind FROM audit ORDER BY rowid")]
assert values[:2] == ["watch", "watch"]
assert sorted(values[2:]) == ["change", "watch"]
con.execute("UPDATE item SET quantity=NULL WHERE id=1")
values = [r[0] for r in con.execute("SELECT kind FROM audit ORDER BY rowid")]
assert len(values) == 5 and values[-1] == "watch"
assert values.count("change") == 1 and "typo" not in values
print("watch count:", values.count("watch"))
print("actual change count:", values.count("change"))
finally:
con.close()第一个触发器订阅不存在的 quantitty,但内部插入日志的 SQL 完全合法。查询 sqlite_schema 能读到这个触发器,随后把真实 quantity 更新为二,日志数量仍为零。这份反例说明,只查迁移是否成功、名称是否存在或表结构是否可读,都不能代替实际写入测试。
修复版本订阅 quantity,并记录固定标记列来证明是否执行。示例保留错误版本一起运行,最终日志没有出现 typo,进一步确认原来的拼写没有暗中变成“监听全部列”。实际修复应审查并替换错误定义,这里并存仅为了让前后差异可见。
UPDATE OF 看的是 SET 中列的出现
正确触发器遇到 SET quantity = quantity 仍会执行,因为列名出现在 SET 左侧;它不自动判断新值是否不同。代码把数量维持为二,日志数量仍增加一次。若触发器用于刷新缓存,这可能符合预期;若用途是记录真实变化,就还需要额外判断。
随后另建带 WHEN 条件的触发器,使用 OLD.quantity IS NOT NEW.quantity 判断两者是否不同。再写相同值时只出现 watch,改成 NULL 时两个触发器都会记录,继续把 NULL 写成 NULL 又只出现 watch。完整的五项日志序列把每一次更新的含义明确固定下来。
变化判断要考虑空值
这里选择 IS NOT,是为了让从普通数字变为 NULL 也能明确判为不同,两个 NULL 则判为相同。若使用普通不等比较,遇到空值会进入三值逻辑,可能使 WHEN 不成立。条件到底表示值变化、状态转换还是任意写入尝试,应由日志用途决定。
示例还更新了 note 字段,两份正确触发器都没有执行。这个反例与相同值更新形成对照:前者根本没有订阅列,后者订阅列出现了但值没变。排查日志缺失时,应先确认触发事件和列,再看 WHEN 条件,最后查看触发器内部语句及其约束。
迁移测试应覆盖触发和不触发两边
每次新建或调整触发器,可以在测试库先准备一行,分别改变订阅列、其他列、相同值以及空值边界,再核对目标日志表的精确内容。多个触发器之间的执行先后不应随意当作业务约定;本例针对会同时执行的那一次使用集合核对,避免依赖未承诺的顺序。
本例只涉及 AFTER UPDATE 的逐行触发器,没有递归更新原表,也不测试并发、回滚或触发器中的外部函数。真实迁移仍需评估日志量与事务影响。最值得保留的检查是:对象定义能够保存之后,还要用代表性写入确认它确实在预期事件上运行。
资料核对日期:2026年10月2日(北京时间)。代码在 CPython 3.12.14 中独立运行;SQLite 示例使用其连接的 SQLite 3.53.1。


