SQLite 复合外键:其中一列为空,为什么剩下一列不再检查匹配
一条记录用租户和编号共同指向父表,租户写成不存在的值,编号却为空,插入竟然成功。若只检查PRAGMA foreign_keys已经打开,很容易把它当成SQLite漏验。真正原因是复合外键对NULL有明确规则:任意一个子键列为空,就不要求存在完整对应的父行。这不是只跳过空的那一列,而是这一组引用匹配获得豁免。
本文在Linux、CPython 3.12.14与运行时SQLite 3.53.1上实跑。数据库均为独立的:memory:连接,测试结束显式关闭,不读取或修改业务数据库。Python版本与SQLite库版本分别记录,因为相同Python版本可能链接不同SQLite构建。 本例在任何数据事务开始前启用并回读外键设置,父表两列显式NOT NULL,避免父主键空值规则混入实验。
AI生成的概念示意图:完整双片钥匙、缺少一半的钥匙与成对空轮廓并置,强调可选关联应以整组字段为单位定义;不是真实软件界面或运行截图。
完整程序与实际输出
保存为demo.py,执行python3 demo.py。程序只使用固定测试输入,代码和本次输出分别列出。
import sqlite3
con = sqlite3.connect(':memory:', isolation_level=None)
try:
con.execute('PRAGMA foreign_keys = ON')
assert con.execute('PRAGMA foreign_keys').fetchone()[0] == 1
con.executescript('''
CREATE TABLE parent(tenant TEXT NOT NULL, code TEXT NOT NULL,
PRIMARY KEY(tenant, code));
CREATE TABLE child(tenant TEXT, code TEXT,
FOREIGN KEY(tenant, code) REFERENCES parent(tenant, code));
CREATE TABLE guarded(tenant TEXT, code TEXT,
CHECK ((tenant IS NULL) = (code IS NULL)),
FOREIGN KEY(tenant, code) REFERENCES parent(tenant, code));
INSERT INTO parent VALUES('T1', 'A');
''')
for table in ('child', 'guarded'):
for pair in [('T1', 'A'), ('missing', None), (None, None), ('T1', 'X')]:
try:
con.execute(f'INSERT INTO {table} VALUES (?, ?)', pair)
state = 'accepted'
except sqlite3.IntegrityError as exc:
state = exc.sqlite_errorname
print(table, pair, state)
print('foreign_key_check:', con.execute('PRAGMA foreign_key_check').fetchall())
finally:
con.close()本次实际标准输出:
child ('T1', 'A') accepted
child ('missing', None) accepted
child (None, None) accepted
child ('T1', 'X') SQLITE_CONSTRAINT_FOREIGNKEY
guarded ('T1', 'A') accepted
guarded ('missing', None) SQLITE_CONSTRAINT_CHECK
guarded (None, None) accepted
guarded ('T1', 'X') SQLITE_CONSTRAINT_FOREIGNKEY
foreign_key_check: []复合引用的匹配对象是一整组
父表只包含T1与A这一组键。child的两列通过一条FOREIGN KEY声明共同引用父表两列,不是两个各自独立的引用。完整值T1、A被接受,完整值T1、X被拒绝,说明数据库确实在检查组合存在性。若拆成两个独立外键,会表达不同规则,甚至可能允许来自不同父行的字段被错误拼在一起。
关键样本missing、None却被接受,尽管missing根本不在父表。Python的None绑定为SQL NULL。由于复合子键中有空列,SQLite不要求找到对应父行,剩下的非空租户也不会单独拿去寻找一个“部分匹配”。因此不能把它理解为“有值的字段照常验证,没值的字段跳过”。
全空的None、None也被接受。这可以很好地表达“目前没有关联”,但前提是业务确实把整组缺失视为允许状态。若业务把missing、None理解成半条未完成的关系,单靠复合外键就不够;数据模型需要另外规定这类半填记录是否合法。
用成对空值检查补上业务规则
guarded表保留同样外键,新增CHECK ((tenant IS NULL) = (code IS NULL))。两个IS NULL表达式各自给出明确真假值,相等只在两列同空或同非空时成立。于是部分空样本被CHECK拒绝,全空仍被接受,完整有效引用通过,完整不存在的引用仍由外键拒绝。
把规则拆成两层有助于定位错误:CHECK负责形状,即一组关联是否填写完整;外键负责完整值是否指向真实父项。错误名称也对应这一分工,部分空得到SQLITE_CONSTRAINT_CHECK,完整但不存在得到SQLITE_CONSTRAINT_FOREIGNKEY。应用可以据此产生不同提示,而不必把所有失败都描述成“编号不存在”。
这里特意使用IS NULL,而不是让列值直接参与普通相等比较。NULL在一般比较中会产生未知结果,而CHECK对NULL结果的处理又可能放过不合格输入。先把是否为空转成明确布尔事实,再比较两列的空值状态,可以避免三值逻辑在校验表达式里留下漏洞。
可选关联与强制关联如何选择
如果这组关联必须始终存在,最直接的模型是两列都加NOT NULL,再保留复合外键。此时全空和部分空都会被列约束拒绝。若整组关联可选,本文的成对CHECK更合适:允许无关联,但不允许只填一半。两者表达不同产品规则,数据库不能替业务方选择。
有的团队尝试在外键后加MATCH FULL,期待数据库自动要求全空或全非空。SQLite文档明确说明它不按这些MATCH变体改变执行语义,因此不能只看SQL语句被解析接受就认为规则已经生效。跨数据库迁移尤其需要运行部分空样本,语法兼容并不保证NULL匹配策略兼容。
字段超过两列时,简单的两项相等表达式需要扩展为整组全部为空或全部非空的条件,不能随意依赖链式相等的直觉。写出可审查的逻辑并针对每一种部分空组合测试,通常比压成最短的一行更可靠。列数越多,业务上是否应该改成一个独立关联对象也越值得重新评估。
让验收同时覆盖写入和后续更新
示例每种表都尝试完整有效、部分空、全空和完整无效四种输入,构成一张最小状态矩阵。实际系统还应测试UPDATE:把完整关联的一列改成NULL,或从全空开始只补一列。若应用分两次提交这两列,成对CHECK会拒绝中间状态,应改成同一条更新语句一次写入完整组合。
表名来自程序中固定的两项清单,所以示例中的格式化只用来选择受控测试表;真实用户值始终通过问号占位绑定。不能把这种写法扩大成接收任意表名的SQL拼接器。参数绑定保护数据边界,但不会替你判断复合键的业务形状,两类保障需要同时存在。
最后foreign_key_check返回空,并不意味着child里的半填记录符合业务要求。它只证明现有记录没有违反SQLite定义的外键条件,而部分NULL本来就得到豁免。这正是技术约束与业务规则不能混为一谈的地方:检查器报告通过,仍可能需要额外的数据质量查询。
若给旧表补加成对约束,应先统计现有的部分空记录,决定修复、删除还是映射到无关联状态,再进行受控迁移。不要在未知数据量与业务含义下自动补默认值,以免把错误的租户和编号强行组成一个看似完整的引用。本文只在内存中新建表,刻意避免把教学约束变更直接施加到真实库。
前端表单也应该把这两列作为一个关联选择结果提交,而不是让用户分别保存。数据库约束是最后一道防线,界面若能明确区分“尚未选择”和“已经选择完整对象”,就能减少半填记录的来源。服务端仍需保留同样校验,因为导入脚本、旧客户端和后台任务并不一定经过这张表单。多入口共同使用数据库时,约束应该由共享数据层保证。
参考资料与验证记录
官方资料核验于2026年10月3日。本次完整程序退出码为0,标准错误为空;例子验证的是上述固定输入与运行环境,不表示所有平台和版本的输出细节完全一致。


