SQLite EXCEPT 做差集:缺少的编号与缺少的明细不能用同一种比较

10-01 3阅读

核对两份导出清单时,常会问“左边有哪些右边没有的内容”。这句话看起来明确,实际却至少有两种意思:缺少哪些编号,或缺少哪些编号与状态的组合。EXCEPT 比较的是查询输出的整行,选择哪些列,本身就在定义什么算同一条。

它返回左查询中存在、右查询中不存在的行,并去除结果重复项。因此两次相同的导入动作,可能只剩一个差异值。要核对发生次数,就必须另外比较数量或引入真正区分每次事件的键,不能把集合差当成保留重复次数的流水对账。

SQLite EXCEPT 做差集:缺少的编号与缺少的明细不能用同一种比较

AI概念配图:用抽象物件说明本文讨论的关系,不是实际运行界面或测量结果。

用一个内存数据库把空值也放进去

下方代码可用 Python 3 直接运行,sqlite3 是标准库模块,不需要创建磁盘数据库。两边的编号列故意包含重复值和 NULL。首先验证 EXCEPT 得到唯一的二;然后换成 NOT IN,观察右侧空值如何使没有匹配的普通值也无法通过过滤。

不要据此把所有 NOT IN 机械替换为 EXCEPT。前者是逐行条件,后者是两组结果的集合运算;除了空值规则不同,重复行是否保留、比较哪些列和希望返回哪些其他字段,都可能改变业务含义。这里并排展示,是为了让差异可验证。

import sqlite3

with sqlite3.connect(':memory:') as db:
    db.executescript("""
        CREATE TABLE lhs(x INTEGER);
        CREATE TABLE rhs(x INTEGER);
        INSERT INTO lhs VALUES (1),(2),(2),(NULL),(NULL);
        INSERT INTO rhs VALUES (1),(NULL);
    """)
    def rows(sql):
        return db.execute(sql).fetchall()
    difference = 'SELECT x FROM lhs EXCEPT SELECT x FROM rhs ORDER BY x'
    assert rows(difference) == [(2,)]
    assert rows('SELECT x FROM lhs WHERE x NOT IN (SELECT x FROM rhs)') == []
    print('EXCEPT:', rows(difference), '; NOT IN: []')
    db.execute('DELETE FROM rhs WHERE x IS NULL')
    assert rows(difference) == [(None,), (2,)]
    db.execute('DELETE FROM rhs')
    assert rows(difference) == [(None,), (1,), (2,)]
    db.executescript("""
        CREATE TABLE old(id INTEGER, state TEXT);
        CREATE TABLE new(id INTEGER, state TEXT);
        INSERT INTO old VALUES (7,'draft'),(7,'draft');
        INSERT INTO new VALUES (7,'done');
    """)
    assert rows('SELECT id FROM old EXCEPT SELECT id FROM new') == []
    full = rows('SELECT id,state FROM old EXCEPT SELECT id,state FROM new ORDER BY id,state')
    assert full == [(7, 'draft')]
    assert rows('SELECT x FROM rhs EXCEPT SELECT x FROM rhs') == []
    print('full-row difference:', full)
    print('NULL, duplicate and empty-side checks passed')

明确 NULL 在这个操作中的含义

SQLite 在复合查询判断重复行时,把 NULL 与另一个 NULL 视为相同。因此右边含空值时,左边的空值会被差集去掉;右边没有空值时,左边的多个空值可以作为一个结果留下。这是此处的集合比较规则,不能推广为普通等号的行为。

程序进一步清空右表,此时左边的不同值都能留下,包括一个空值。每条用于展示的查询都明确写了 ORDER BY,避免把某次运行偶然出现的顺序当成承诺。最终差集为空也很正常,表示按当前投影定义没有差异,并不证明所有原始字段都一样。

第二组表让两个快照都含编号七,但状态从 draft 变为 done。只比较编号时结果为空;同时比较编号和状态时,旧的 draft 组合仍在左侧差集中。这个小例子能防止对账程序只因主键存在,就忽略已经发生的内容变化。

先定义差异单位,再选择查询形式

实际落地时,先把需要比较的字段列出来,统一两边的类型与文本比较规则,并检查查询输出列数一致。不要无意中加入来源标签或导出时间,否则即使业务内容相同,整行也会因为附加列不同而被判为差异;这些信息可在结果产生后补充。

如果目标是找出某些编号对应的完整左侧记录,可以先形成缺失编号集合,再回到左表关联明细。这样能明确差集阶段的比较单位,也保留后续需要展示的字段。若左表本身允许同一编号多行,关联之后返回几行仍需由业务说明。

含空值的业务键通常值得单独检查。允许空值参与对账、将其归入待补全清单,还是直接拒绝本批数据,是输入规则;集合运算不会替你判断哪个选择合理。不要为了让查询有结果就随手加一个默认编号,造成不同记录被错误合并。

验收应包含左右相同、右侧为空、双方都空、左侧重复、仅一边有空值以及同编号不同状态。把每组预期写清楚,再执行查询,才能确认得到的“缺少”确实是业务要问的缺少,而不是某个看起来方便的查询语法所定义的缺少。

参考资料

文章版权声明:除非注明,否则均为云鹊BLOG原创文章,转载或复制请以超链接形式并注明出处。