SQLite CHECK 约束:写了数量大于零,为什么 NULL 仍能插入

10-01 3阅读

条件看起来够严,空值还是通过了

给数量列写上 CHECK(qty > 0),通常是想保证每一条记录都有正数数量。但导入缺失数据时,NULL 仍可能写入成功。原因是 SQLite 的 CHECK 只在表达式求值并转成数值后等于零时判定违反约束;结果为 NULL 或非零时,都不算这项约束失败。

因此,“数值必须符合条件”和“数值必须存在”是两项要求。NULL 与零不同,也不等于空字符串。把正数检查当成必填检查,会让缺失值绕过原本看似完整的规则,直到汇总、计算或展示时才显露问题。

用两张表划清宽松与严格的规则

下面使用 Python 自带 sqlite3 建立内存数据库,直接保存运行即可,不需要连接实际数据库。示例先在 loose 表中保存 NULL 并拒绝零;再建立 strict_qty,把必填、存储类型和正数范围分别表达出来,同时增加已用数量不能超过总量的跨列检查。

这里的严格指本例业务规则,并未使用 SQLite 的 STRICT 表选项。代码通过 typeof 检查进入表后的实际存储类别,因此既能观察类型亲和性的转换,也能避免误以为声明 INTEGER 就完全拒绝其他类型。所有写入都采用参数绑定,异常分支明确验证拒绝发生。

SQLite CHECK 约束:写了数量大于零,为什么 NULL 仍能插入

AI概念配图,非真实界面

import sqlite3

con = sqlite3.connect(':memory:', isolation_level=None)
con.execute('CREATE TABLE loose(qty INTEGER CHECK(qty > 0))')
con.execute('INSERT INTO loose VALUES (?)', (None,))
assert con.execute('SELECT qty FROM loose').fetchall() == [(None,)]

def rejected(sql, values):
    try:
        con.execute(sql, values)
    except sqlite3.IntegrityError:
        return
    raise AssertionError('invalid data accepted')

rejected('INSERT INTO loose VALUES (?)', (0,))
print('CHECK alone: NULL accepted, zero rejected')
con.execute("""CREATE TABLE strict_qty(
    qty INTEGER NOT NULL CHECK(typeof(qty) = 'integer' AND qty > 0),
    used INTEGER NOT NULL CHECK(typeof(used) = 'integer' AND used >= 0),
    CHECK(used <= qty)
)""")
con.execute('INSERT INTO strict_qty VALUES (?, ?)', (5, 2))
for values in [(None, 0), (0, 0), (-1, 0), (2.5, 0),
               ('oops', 0), (5, None), (5, -1), (5, 6)]:
    rejected('INSERT INTO strict_qty VALUES (?, ?)', values)
rejected('UPDATE strict_qty SET used = ? WHERE rowid = ?', (6, 1))
assert con.execute('SELECT qty, used FROM strict_qty').fetchall() == [(5, 2)]
print('required, integer, range and update checks passed')

con.execute('INSERT INTO strict_qty VALUES (?, ?)', ('12', 0))
assert con.execute('SELECT typeof(qty) FROM strict_qty WHERE rowid=2').fetchone() == ('integer',)
print('numeric text is stored as integer after affinity')
con.close()

读懂每项约束承担的责任

第一张表成功留下一个空值,说明比较未知数量是否大于零仍然未知。第二张表先用 NOT NULL 阻止缺失,再用 CHECK 要求整数存储和正数。已用数量另有非空、整数及非负要求,最后由 used <= qty 限制二者关系,每一项都能对应到具体错误样本。

断言还测试了更新:把现有行的已用数量改得超过总量,应得到完整性异常,而且原来的值保持不变。只检查插入是不够的,因为一条起初有效的记录,也可能被后续某个字段更新破坏。将关系写到表级约束,能让不同写入路径接受同一项检查。

数据库检查的是转换后的存储值

INTEGER 类型亲和性可能把可转换的文本数字存成整数。因此字符串形式的十二可以通过示例检查,不能据此断言调用方一定传入了整数对象。若接口要求原始输入必须为整数,应在应用入口另做验证;数据库约束负责的是数据库最终保存的值。

相反,无法转成整数的文本和带小数部分的实数在本例会被拒绝。不要只写一个大小比较就把它当成完整类型校验,因为 SQLite 的比较还受存储类别与类型亲和性影响。代码用小数和普通文本做反例,正是为了暴露声明类型与存储结果之间的区别。

跨列检查也要逐项看空值路径。如果总量允许 NULL,那么已用量不超过总量这个表达式也可能变成 NULL,从而不触发失败。与其把许多含空值的条件堆在一起,不如先决定每列是否可缺失,再分别表达条件适用时的范围和关系。

部署约束前,先验证旧数据与失败处理

CHECK 在插入或更新时执行,不会因为读取一次表就重新验证整份历史数据。给已有系统收紧规则之前,需要单独盘点空值、异常类型和越界行,确定迁移方式。示例没有展示修改生产表,也没有开启忽略约束的选项,读者应先在副本中验证自己的迁移过程。

应用端捕获异常后也应给出明确结果,不能悄悄把失败当成成功。批量写入还需要另行规定事务范围,单条检查失败并不自动替你选择整批撤回还是逐条记录错误。把必填、类型、范围、关系和事务策略分开,约束才能成为可解释的业务边界。

参考资料

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