SQLite LEFT JOIN 计数:没有子记录,为什么 COUNT(*) 仍然是 1
先写下零条记录也要出现的需求
项目看板要列出全部项目,并显示各自尚未完成的任务数。甲项目有一条未完成任务,乙项目只有已完成任务,丙项目从来没有任务。正确结果应是甲一条、乙零条、丙零条。这个小例子同时检验两个要求:数字必须准确,零任务项目也必须留在列表里。
LEFT JOIN 为没有匹配子记录的左表行补出一行,右表各列用 NULL 表示。于是“没有任务”并不等于“连接结果没有行”。COUNT(*) 统计分组内的结果行数,会把补出来的这一行也算进去;COUNT(child.id) 只统计该列非空的行,才可能得到零。
把子表条件放在它真正约束的位置
当需求是保留全部项目、只计算未完成任务时,应把任务状态条件放进 ON,与项目编号的匹配条件一起判断。没有任何合格任务的项目会得到补空行。若把同一个状态条件移到 WHERE,补空行里的状态比较不能得到真,这些项目便会被过滤掉。
这里需要区分“哪些任务算匹配”与“哪些项目应出现在最终报表”。前者属于这次连接的匹配约定;例如只展示仍在运营的项目,则可以用 WHERE 筛选左表字段。不能机械地说所有过滤条件都必须写进 ON,要先说明过滤的是哪一层业务对象。
用四份结果定位差异
下面只依赖 Python 自带的 sqlite3,数据库建在内存中,结束后关闭连接。三条任务包含一个备注为空的真实任务,因此还能观察“用可空业务字段计数”的另一个陷阱。每个查询都按项目编号排序,并用断言核对完整结果,避免只看到第一行正确便宣布成功。
AI生成概念示意图,非真实界面
import sqlite3
from contextlib import closing
with closing(sqlite3.connect(':memory:')) as db:
db.executescript("""
CREATE TABLE project(id INTEGER PRIMARY KEY, name TEXT NOT NULL);
CREATE TABLE task(
id INTEGER PRIMARY KEY,
project_id INTEGER NOT NULL,
status TEXT NOT NULL,
note TEXT
);
INSERT INTO project VALUES (1, 'A'), (2, 'B'), (3, 'C');
INSERT INTO task VALUES
(11, 1, 'open', NULL),
(12, 1, 'closed', 'done'),
(21, 2, 'closed', 'done');
""")
all_rows = db.execute("""
SELECT p.id, COUNT(*), COUNT(t.id)
FROM project AS p
LEFT JOIN task AS t ON t.project_id = p.id
GROUP BY p.id ORDER BY p.id
""").fetchall()
on_rows = db.execute("""
SELECT p.id, COUNT(*), COUNT(t.id), COUNT(t.note)
FROM project AS p
LEFT JOIN task AS t
ON t.project_id = p.id AND t.status = 'open'
GROUP BY p.id ORDER BY p.id
""").fetchall()
where_rows = db.execute("""
SELECT p.id, COUNT(t.id)
FROM project AS p
LEFT JOIN task AS t ON t.project_id = p.id
WHERE t.status = 'open'
GROUP BY p.id ORDER BY p.id
""").fetchall()
workaround = db.execute("""
SELECT p.id, COUNT(t.id)
FROM project AS p
LEFT JOIN task AS t ON t.project_id = p.id
WHERE t.status = 'open' OR t.id IS NULL
GROUP BY p.id ORDER BY p.id
""").fetchall()
assert all_rows == [(1, 2, 2), (2, 1, 1), (3, 1, 0)]
assert on_rows == [(1, 1, 1, 0), (2, 1, 0, 0), (3, 1, 0, 0)]
assert where_rows == [(1, 1)]
assert workaround == [(1, 1), (3, 0)]
print('all:', all_rows)
print('filtered ON:', on_rows)
print('filtered WHERE:', where_rows)
print('nullable workaround:', workaround)all 一行的三个元组分别为 (1,2,2)、(2,1,1)、(3,1,0),后两列是结果行数与实际任务数。filtered ON 输出 (1,1,1,0)、(2,1,0,0)、(3,1,0,0),最后一列是非空备注数。甲有真实任务但备注为空,所以备注计数不能代替任务计数。
filtered WHERE 只剩 (1,1)。nullable workaround 虽然重新保留了丙项目,却仍然漏掉乙项目。这证明把条件改成“状态未完成,或者任务编号为空”并不能普遍修复问题:乙确实匹配过一条已完成任务,连接阶段不会另外给它补一条空记录。
选好计数字段,再检查连接倍增
示例的任务编号定义为 INTEGER PRIMARY KEY,真实任务有可用于计数的非空编号。实际表结构不一定一样,应先核对列是否真的保证非空。联系人、备注、完成时间等业务字段允许缺失,用它们计数回答的是“有该字段的记录有多少”,不能自动代表子记录总数。
给 COUNT(child.id) 再套一个把空值改零的函数,通常不会解决这里的问题;这个聚合本身已经能返回零。若项目整行被 WHERE 删掉,再怎么处理计数结果也无法恢复该项目。排查时应先观察连接后的明细行,再判断聚合字段,最后核对过滤之后留下了哪些项目。
还要留意第二张一对多表。一个任务有两条标签记录时,继续连接标签可能让同一个任务出现两次。此时直接计数编号仍会得到两条,需要按明确业务口径选择先汇总任务,或对任务编号去重。去重也有成本与含义,不能把它当成所有重复结果的统一补丁。
上线前保存无任务、只有不合格任务、同时有合格与不合格任务这三组样本。变更筛选条件后,既比较数值,也比较项目编号集合。报表遗漏一整行往往比某行多算一条更难被发现,因此“零也要显示”应写成可以执行的验收条件。


