对照实际连接的行,核查 AI 给出的 SQL 查询
本文目录
核查 SQL 计数时,先写清每张源表的一行代表什么,再查看连接产生了哪些行,按问题要求的对象计数,不要假定每条连接结果都代表一个这样的对象。然后用一套可以手工算出结果的小数据,执行完整查询。查询即使没有报错,也可能回答了错误的计数问题。
作者:CueSpec Workflow Editors · AI 辅助团队编辑。
本教程按团队统计项目数、检查事件数,以及通过的检查事件数。它提供完整数据、一个故意写错的查询、该查询的实际输出、七行诊断结果和完整可用的修正。全部输入都是原创虚构教学数据。SQL 已在本地运行;错误解释是为示例编写的,不是从 AI 助手取得的回答。没有使用模型或生产数据库。
一个较早的双表计数问题说明了根本难点:连接组合的数量,可能超过提问者想统计的对象数量。一则较早的 AI 辅助 SQL 讨论包含不同的个人经历。它们都不能证明当前模型的准确率,也不能把这个问题归为 AI 独有。自己写的查询同样需要这些检查。
先确定问题和每一行的含义
问题是:每个团队有多少个项目、发生了多少次检查事件,其中多少次通过?没有检查事件的项目也要计入。
| 来源或输出 | 一行代表什么 | 键或规则 |
|---|---|---|
projects |
一个项目 | project_id 是唯一、非空的文本;项目属于该行给定的 team。 |
checks |
一次检查事件 | check_id 是唯一、非空的文本;project_id 引用所提供的一个项目。 |
| 结果 | 一个团队 | 列为 team、projects、checks、passed;没有事件的项目仍计作一个项目。 |
两次事件可以有相同结果,却不是同一次事件。C1 和 C2 都是 P1 的 pass,但事件 ID 不同,两次都必须计入。P4 是 West 的项目,没有检查事件。它应该贡献一个项目、零次检查,而不是从报告中消失。
这与询问某个 ID 是否出现在另一份清单中不同。这里不做标识符规范化、不查找清单缺失项,也不分配匹配对。这个关系明确允许一个项目对应多次事件。
使用完整的四项目、六事件示例数据
把下面内容保存为 schema-seed.sql。它包含全部原始行和键约束,因此不依赖缺失的数据库或附件。
PRAGMA foreign_keys = ON;
CREATE TABLE projects (
project_id TEXT PRIMARY KEY NOT NULL,
team TEXT NOT NULL
);
CREATE TABLE checks (
check_id TEXT PRIMARY KEY NOT NULL,
project_id TEXT NOT NULL REFERENCES projects(project_id),
outcome TEXT NOT NULL
);
INSERT INTO projects (project_id, team) VALUES
('P1', 'North'),
('P2', 'North'),
('P3', 'South'),
('P4', 'West');
INSERT INTO checks (check_id, project_id, outcome) VALUES
('C1', 'P1', 'pass'),
('C2', 'P1', 'pass'),
('C3', 'P1', 'fail'),
('C4', 'P2', 'pass'),
('C5', 'P3', 'fail'),
('C6', 'P3', 'pass');
North 拥有 P1 和 P2,South 拥有 P3,West 拥有 P4。P1 有三次检查,P2 一次,P3 两次,P4 没有。因此,运行查询之前就能知道,项目数分别为2、1、1。数检查 ID,可得事件数4、2、0;数通过的事件,可得3、1、0。
基准场景的完整手工预期为:
| team | projects | checks | passed |
|---|---|---|---|
| North | 2 | 4 | 3 |
| South | 1 | 2 | 1 |
| West | 1 | 0 | 0 |
独立手工编写的 expected.json在执行前保存,其中还包含四个场景的完整预期诊断,以及已知的错误结果。这些值都不是由任一查询生成的。对于这套很小的输入,先手工检查源记录,就足以在请助手协助前确定预期计数。
运行一个看似合理、实际数错对象的查询
下面是完整的人工错例 wrong.sql:
SELECT
p.team,
COUNT(*) AS projects,
COUNT(*) AS checks,
SUM(CASE WHEN c.outcome = 'pass' THEN 1 ELSE 0 END) AS passed
FROM projects AS p
LEFT JOIN checks AS c ON c.project_id = p.project_id
GROUP BY p.team
ORDER BY p.team;
它执行时没有发生 SQL 异常。实际观察到的完整基准结果为:
| team | projects | checks | passed |
|---|---|---|---|
| North | 4 | 4 | 3 |
| South | 2 | 2 | 1 |
| West | 1 | 1 | 0 |
与它配套的完整故意错误解释如下:
故意错误——人工编写的教学反例
我把 projects 与 checks 连接,然后对两个指标都统计连接后的行数。
North 有4个项目、4次检查、3次检查通过。South 有2个项目、
2次检查、1次检查通过。West 有1个项目、1次检查、0次检查通过。
因此,完整结果共包含7个项目、7次检查,其中4次检查通过。
输出形状看起来合理:三个团队、三个整数计数。North 的检查数和通过次数甚至符合问题要求。但这些局部正确,并不能证明项目计数或 West 的检查数正确。源数据有四个项目、六次事件,错误列却分别合计为七和七。
查看全部七条连接结果
增加更多聚合之前,先运行 diagnostic.sql:
SELECT p.project_id, p.team, c.check_id, c.outcome
FROM projects AS p
LEFT JOIN checks AS c ON c.project_id = p.project_id
ORDER BY p.project_id, c.check_id;
下面是实际观察到的完整基准诊断。NULL 表示右侧列中的 SQL 空值,不是某条新检查记录里存储的字符串。
| project_id | team | check_id | outcome |
|---|---|---|---|
| P1 | North | C1 | pass |
| P1 | North | C2 | pass |
| P1 | North | C3 | fail |
| P2 | North | C4 | pass |
| P3 | South | C5 | fail |
| P3 | South | C6 | pass |
| P4 | West | NULL | NULL |
P1 贡献了三条连接结果,所以数这些行,并不会只把 P1 数一次。P3 贡献两条。P4 贡献一条未匹配的左侧记录,右侧列为空:这一行让项目留在结果中,但它不是一次检查事件。
SQLite 的 SELECT 文档解释了连接组合,以及未匹配左侧记录的右侧空列。聚合函数定义则区分了统计行数的 COUNT(*),以及统计 X 中非空值数量的 COUNT(X)。你仍然需要判断,哪一行或哪一个值对应问题中要数的对象。
不要通过对 outcome 去重来解决。那会把 C1 和 C2 合并,但问题统计的是事件,不是不同的结果标签。也不要给整个连接报告加上 WHERE c.outcome = 'pass':它既会从检查总数中剔除未通过的事件,也会移除 P4 那条右侧补空值的记录。要求的报告应包含所有事件和零事件项目。
先把事件汇总为每项目一行,再统计项目
完整修正见 correct.sql:
WITH per_project AS (
SELECT
project_id,
COUNT(*) AS checks,
SUM(CASE WHEN outcome = 'pass' THEN 1 ELSE 0 END) AS passed
FROM checks
GROUP BY project_id
)
SELECT
p.team,
COUNT(*) AS projects,
SUM(COALESCE(c.checks, 0)) AS checks,
SUM(COALESCE(c.passed, 0)) AS passed
FROM projects AS p
LEFT JOIN per_project AS c ON c.project_id = p.project_id
GROUP BY p.team
ORDER BY p.team;
per_project 阶段读取的是实际事件行。这里的 COUNT(*) 统计事件,条件求和统计通过的事件。按 project_id 分组后,每个有检查事件的项目最多得到一条聚合记录。
外层查询再把这个结果与 projects 连接。现在每个项目恰好贡献一行参与外层聚合,因此这个阶段的 COUNT(*) 统计的是项目。P4 没有事件聚合行,COALESCE 为它的检查数和通过次数提供零。没有任何原始事件被删除或与另一事件合并。
实际观察到的完整修正基准结果为:
| team | projects | checks | passed |
|---|---|---|---|
| North | 2 | 4 | 3 |
| South | 1 | 2 | 1 |
| West | 1 | 0 | 0 |
这三行合计对应全部四个源项目、六个检查 ID 和四次通过事件。基准输出记录包含完整诊断、错误结果和正确结果,以及列名和实际运行时版本。
直接连接时,也可以分别用明确去重的项目键和非空事件键来统计。但这不意味着“加上 DISTINCT”是通用修复。再增加一种一对多关系,或改变要求统计的对象,就会改变需要检查的内容。上面的预聚合是这道给定双表问题的一种清楚解法,不是性能基准,也不声称它是每种数据库中的最优 SQL。
保持修复请求完整
如果错误查询是由助手协助起草的,应提供实际输入和观察到的输出,不要只说“修好计数”。本例的完整指令如下:
只使用这份完整的虚构 JOIN-01 材料,修复下面完整的故意错误查询及解释。
返回完整修正后的 SQLite SELECT、基准场景的完整七行诊断,以及按团队
汇总的完整三行结果;然后逐项解释修复后的计数怎样追溯到项目 ID 和
检查 ID。不要只建议添加 DISTINCT,也不要只返回一行修正结果。
问题:按团队统计项目数、独立检查事件数和通过的检查事件数。
包含没有检查事件的项目。保持原始 team、project_id、check_id、outcome
字段及所提供的键不变。一条项目记录代表一个实体,一个 check_id 代表
一次独立事件。C1 和 C2 是两次独立的通过事件。P4 即使没有事件,也属于
West。本例每个团队输出一行,依次为 North、South、West。
保留原始数据。不要把右侧为空的占位行计作事件。不要丢弃重复出现的
pass 标签,不要把整个结果筛成仅含 pass 的行,也不要把问题改成清单
成员对账。说明每个查询阶段的一行究竟代表一次事件还是一个项目。
下面的错误查询确实已在本地用虚构输入运行;错误文字是人工编写的反例,
不是取得的模型输出。不要声称运行过模型,也不要声称你返回的查询已经
执行。只准备文字。之后如需执行,必须使用可丢弃的本地玩具数据副本,
另行报告实际引擎和结果。不要连接生产数据库、上传数据、安装软件包
或访问账号。
完整修复材料在指令后附上全部表结构与初始数据、完整错误查询、实际观察到的完整三行错误输出,以及完整的人工错误解释,可以独立使用。示例的正确查询和完整修正解释单独提供,不会悄悄变成修复输入已经给出的依据。
修正解释如下:
所需的项目数以项目实体为单位,检查数以单次检查事件为单位。
North 有2个项目、4次检查事件、3次检查通过。South 有1个项目、
2次检查事件、1次检查通过。West 有1个项目,没有检查事件,
因此 checks 和 passed 均为0。完整三行输出共计4个项目、
6次检查事件和4次通过的检查事件。
P1 在原始连接诊断中出现三次,因为它有三个不同的检查 ID。
C1 和 C2 是两次独立的通过事件,不是应当丢弃的重复结果记录。
P3 出现两次,因为它有 C5 和 C6。P4 没有事件;其右侧空列
并不描述一次新检查。七条连接结果是匹配组合加上未匹配的 P4 行,
不是七个项目或七次检查的清单。
修正查询先把实际检查汇总为每项目一行,再与四条项目记录连接。
现在外层查询的 COUNT(*) 统计项目,COALESCE 为没有事件的项目
提供零事件计数。
基准场景的错误查询、诊断和修正查询,确实已在本地用 SQLite 3.51.0
对这套虚构数据执行。四个规定的数据场景都与独立手工编写的预期行一致。
没有测试模型,也没有访问生产数据库或客户数据。这些观察不能证明
大规模数据下的性能,也不能证明它适用于不同表结构或不同问题。
这里没有把任何请求提交给模型。观察到的失败,是在玩具数据上执行人工编写的错误 SQL 得到的结果,不是对某个 AI 产品失败率的测量。
改变数据,不要只信任基准场景
查询可能碰巧符合一组输入。下面三个互相独立的改动,用来检查它是否保留了多次事件和零事件项目。每个场景都在新的内存数据库中,从原始初始数据开始。
| 场景 | 精确改动 |
|---|---|
baseline |
不改动。 |
add_c7 |
插入独立事件 ('C7', 'P2', 'pass')。 |
remove_c5_c6 |
只移除 C5 和 C6;P3 仍保留在 projects 中。 |
empty_checks |
删除全部检查事件,保留全部四个项目。 |
实际观察到的三个变化场景的完整修正结果为:
| Case | team | projects | checks | passed |
|---|---|---|---|---|
| add_c7 | North | 2 | 5 | 4 |
| add_c7 | South | 1 | 2 | 1 |
| add_c7 | West | 1 | 0 | 0 |
| remove_c5_c6 | North | 2 | 4 | 3 |
| remove_c5_c6 | South | 1 | 0 | 0 |
| remove_c5_c6 | West | 1 | 0 | 0 |
| empty_checks | North | 2 | 0 | 0 |
| empty_checks | South | 1 | 0 | 0 |
| empty_checks | West | 1 | 0 | 0 |
增加 C7 后,North 的检查数和通过次数增加,项目数不增加。移除 South 的事件后,它的一个项目仍留在结果中。所有地方都没有事件时,三个团队仍然出现,保留各自项目数,事件计数为零。
四个场景确实使用 Python 3.9.6 和 SQLite 3.51.0,在四个独立本地进程中运行,每个进程的退出状态均为0。每个场景的完整诊断、已知错误输出和修正输出,都与各自手工推导的预期一致。与预期的错误输出一致,只证明缺陷被重现,不证明错误查询正确。变化场景的记录见 add_c7、remove_c5_c6 和 empty_checks。
在自己的电脑上重跑这个小示例
把表结构与初始数据、三个 SQL 文件、expected.json 和 run_example.py放在一起。使用包含标准库 SQLite 模块的本地 Python 3,在该目录中运行:
python3 run_example.py baseline
python3 run_example.py add_c7
python3 run_example.py remove_c5_c6
python3 run_example.py empty_checks
每条命令都会打印三个查询的完整结果行、数据改动、实际 Python/SQLite 版本及比较结果。它不创建数据库文件,也不安装软件包。退出0表示所有指定比较一致,退出1表示有比较失败,执行错误也会使命令失败。你的运行时版本可能与记录的不同,因此要查看自己的输出。本地使用说明解释了应保留哪些文件,以及预期结果怎样准备。
这些观察仅覆盖这套虚构表结构和四个明确的数据场景。没有测试远程数据库、额外连接、生产权限、数据上传、模型、大数据性能或业务成效。在真实系统中使用查询之前,应重新说明该系统的键和每一行的含义,并用结果独立已知、获得授权的小示例验证。把检查过的行和完整结果,与最终决定使用的查询一起保存。