先把问题写成“是否存在”
要找完成某个练习模块的人,问题是:对名单中的每个人,是否至少存在一条符合条件的记录?一个人有两次完成记录,名单中仍只显示一次。可用 EXISTS 表达匹配存在性,无须先展开匹配明细。
本文的输出粒度是一名学习者一行,输入名单中的 learner_id 是主键。EXISTS 为每一条外层记录作一次真假筛选,不会因为内层有多个匹配而复制该外层行。不过它也不会自动清理外层本来就有的重复行;必须先保证外层数据粒度与任务一致。
建立能够揭露错误的最小数据
在空的练习数据库中执行 CREATE TABLE learners (learner_id INTEGER PRIMARY KEY, name TEXT); 然后执行 CREATE TABLE attempts (attempt_id INTEGER PRIMARY KEY, learner_id INTEGER, module TEXT, state TEXT); 本例可在 SQLite 中运行,查询也采用 PostgreSQL 支持的基本语法。全部名称与记录均为虚构。
插入名单:INSERT INTO learners VALUES (10,'青杉'),(20,'晴川'),(30,'北辰'),(40,'星桥'); 再插入记录:INSERT INTO attempts VALUES (1,10,'M1','done'),(2,10,'M1','done'),(3,20,'M1','started'),(4,30,'M2','done'),(5,NULL,'M1','done');
青杉重复完成 M1;晴川只开始 M1;北辰完成的是 M2;星桥没有记录;最后一条无法关联到具体学习者。它们分别检验重复、状态、模块、缺失匹配与空关联键,避免只拿一个顺利匹配的样本来宣布查询正确。
写出相关子查询并手算结果
查询为 SELECT l.learner_id, l.name FROM learners AS l WHERE EXISTS (SELECT 1 FROM attempts AS a WHERE a.learner_id = l.learner_id AND a.module = 'M1' AND a.state = 'done') ORDER BY l.learner_id; 预期只有一行:10,青杉。
阅读时把 l 暂时看作当前检查的名单行。a.learner_id = l.learner_id 把内层匹配限制到这个人;模块和状态继续缩小符合条件的记录范围。对于青杉,内层找到两行,但外层只保留青杉这一行。SELECT 1 的“1”是习惯写法,这个普通存在性查询判断的是有没有结果行,而不是返回数字一的个数。
漏掉关联条件,会把局部问题变成全局问题
如果误删 a.learner_id = l.learner_id,子查询便只问整个 attempts 表里是否有人完成 M1。由于确实存在这样的记录,每个学习者都会通过筛选,得到 10、20、30、40 四行。语法仍合法,错误发生在问题含义上,因此必须用预期名单检查,而不是只看查询是否运行。
用清楚的内外层别名,分别检查“当前是谁”“目标模块”“完成条件”。先写完整定义,再考虑性能。数据库可能改写查询执行方式,因此逐人手算只是语义解释,并不保证底层的执行顺序。
用 NOT EXISTS 找没有符合记录的人
把上一条查询中的 WHERE EXISTS 改为 WHERE NOT EXISTS,其余条件不动。结果应为 20,晴川;30,北辰;40,星桥。含义是“没有完成 M1 的匹配记录”,不是“完全没有任何记录”。晴川和北辰虽然有记录,但都不满足全部条件,所以仍被保留。
若题目改为“从来没有任何尝试的人”,则应去掉内层的模块与状态条件,只保留学习者关联;此时只有星桥。反向筛选最容易把“没有符合条件的记录”误读成“所有记录都不符合另一个随意改写的条件”。不要同时反转 state 条件和 EXISTS,除非已证明新逻辑表达同一个问题。
空关联键为何需要单独检验
本例的空关联键记录无法通过 a.learner_id = l.learner_id 与任何非空主键匹配,因此不会改变上述 EXISTS 与 NOT EXISTS 的名单。这与把同一问题直接改写成 NOT IN 并不等价:如果内层返回的 learner_id 含 NULL,未命中的成员判断可能成为未知,从而被 WHERE 排除。
具体地,查询 SELECT learner_id FROM learners WHERE learner_id NOT IN (SELECT learner_id FROM attempts WHERE module = 'M1' AND state = 'done'); 在本例会得到空结果。它不是预期的三人名单。本节只把空值作为存在性查询的一个边界检查;更换语法前仍应说明键能否为空,以及缺失关联在业务上应如何处理。
自练、预期变化与迁移方法
先预测,再插入 INSERT INTO attempts VALUES (6,20,'M1','done'); 原 EXISTS 查询现在应返回青杉与晴川两行,NOT EXISTS 则返回北辰与星桥两行。再插入一条青杉的 M1 完成记录,两个名单都不应变化。这是很有用的性质检查:新增重复匹配不应改变存在性答案。
还有一个思考题:只选姓名时,两个不同 ID 恰好同名,该不该用 DISTINCT 消成一行?若题意是一人一行,答案是不该;应保留 ID 来区分对象。迁移到有无订单、是否提交、是否有权限等问题时,依次写出外层粒度、关联键、有效匹配条件、正向或反向需求,再设计零条、一条、多条和不合格匹配的测试。真实课程资料应按要求匿名化,查询与报告由学生独立完成。
参考资料
下一步
准备数据库 Tutorial 时,可带上匿名化的小数据、预期行数和你写出的匹配条件,逐项说明每个对象为何应当保留或排除。
