对照实际连接的行,核查 AI 给出的 SQL 查询

使用 AI 辅助编写与复核。

本文目录

核查 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表示有比较失败,执行错误也会使命令失败。你的运行时版本可能与记录的不同,因此要查看自己的输出。本地使用说明解释了应保留哪些文件,以及预期结果怎样准备。

这些观察仅覆盖这套虚构表结构和四个明确的数据场景。没有测试远程数据库、额外连接、生产权限、数据上传、模型、大数据性能或业务成效。在真实系统中使用查询之前,应重新说明该系统的键和每一行的含义,并用结果独立已知、获得授权的小示例验证。把检查过的行和完整结果,与最终决定使用的查询一起保存。