报表字段扩展中的 NULL 与零值
10 月 8 日,我为一个报表增加了新的数量分类。改动看起来只是多一列,但旧记录没有这一项,旧客户端也不会提交它。如果从数据库到界面一路补成 0,报表会把“没有填写”写成“确认没有”。
这次采用可空字段,并把旧客户端不能覆盖新分类作为兼容要求,同时检查幂等、修订和导出。本文根据当天记录事后整理,使用 A、B、C、D 表示分类,数字和代码均为匿名化示例。
NULL 保留的是尚未得到的信息
假设报表原来有 A、B、C 三类,后来增加 D。新增字段时,历史记录只知道前三类的数量,没有理由替它们确认 D 为零。
| A | B | C | D | 含义 |
|---|---|---|---|---|
| 10 | 4 | 0 | NULL | D 尚未填写 |
| 10 | 4 | 0 | 0 | D 已填写,数量为零 |
| 10 | 4 | 0 | 5 | D 已填写,数量为五 |
三行数据的差别在于信息是否已经被确认。
数据库允许 NULL,只解决了存储层的一部分问题。页面初始化、接口默认值、修订记录以及导出转换,都可能再次把它压成零。例如下面这类写法:
// 简化反例:未填写会在进入表单时变成零。const initialD = record.d ?? 0;如果表单保存时又提交 initialD,一次没有改动 D 的编辑也可能产生“用户填写了零”的结果。显示占位符与真正写入数值需要分开:页面可以显示“未填写”,输入框可以为空,但不能因此替用户确认数量。
旧客户端省略字段,不能抹掉新数据
兼容修改还有另一条路径:新客户端先保存了 D=5,旧客户端随后只修改 A。它提交的内容没有 D,因为旧版本根本不知道这个分类。
如果服务端重建完整对象,把省略的字段统一填成 NULL 或 0,原来的 D=5 就会消失。一次 A 的修改不应该顺便修改 D。
因此,接口至少要区分“没有提供这个属性”和“提供了一个值”。下面演示字段保留规则:
type ReportPatch = { d?: number | null };
function nextD(existing: number | null, patch: ReportPatch) { if (!Object.hasOwn(patch, "d")) return existing; return patch.d;}省略 D 时保留已有值;显式提交 0 时,零必须保留下来,不能用真假判断跳过:
// 简化反例:d 为 0 时不会执行更新。if (patch.d) updateD(patch.d);这个属性存在性示例没有包含输入验证。数值范围、非整数、undefined 以及主动清空的 null,仍要按接口契约处理。尤其要说明显式 null 的含义,不能把它和旧客户端省略字段混成同一种更新操作。
这次修复的兼容目标很具体:旧客户端继续修改自己认识的分类时,不能抹掉已经保存的新分类。数据库可空并不意味着任何输入都可以直接写入。
汇总不能偷偷改口径
接下来是统计。PostgreSQL 的 sum 和 avg 按非空输入计算,count(字段) 统计非空值数量,而 count(*) 统计行数;没有输入行时,sum 返回 NULL。聚合函数官方文档列出了这些行为。
下面的 SQL 只读查询合成数据,可以放在独立测试连接中理解统计口径,不会创建或修改表:
WITH sample(d) AS ( VALUES (NULL::integer), (0), (5))SELECT count(*) AS report_count, count(d) AS filled_count, sum(d) AS known_quantity, avg(d) AS filled_averageFROM sample;按这组示例计算,有三份报表、两份填写了 D,已填写数量合计为 5,已填写报表的平均值为 2.5。如果先把 NULL 全补成零,平均值就会按三份报表计算,统计对象已经变了。
“合计为 5”也不能自动读成“全部报表的 D 总量为 5”,因为还有一份没填。汇总可以同时给出已知数量与填写覆盖情况,让读者知道缺了多少信息。需要采用“缺省按零计”的业务口径时,应把这一规则明写出来,不能让一个 COALESCE 悄悄决定含义。
判断是否为空也要使用 IS NULL,不要写 d = NULL。普通比较遇到 NULL 会得到未知结果,这一点在 PostgreSQL 比较函数文档中有说明。
幂等、修订和导出要一起检查
新分类进入保存链路后,还会影响“同一次提交重放是否仍是同一次操作”。检查需要覆盖整个数据流中的幂等、修订和导出路径。
幂等检查要同时覆盖 D 的缺省、零值和非零值。重放相同提交不能产生额外修改;内容已经改变时,也不能误复用之前的结果。具体哪些字段属于幂等内容,要由现有接口契约决定,不能只因为新增了 D 就另造一套规则。
修订检查则关心变更是否可见。NULL → 0 表示从未填写变成确认零值,应该与“值没有变化”区别开;旧客户端只修改 A 时,D 应保持原值。这两个场景很适合放在兼容回归中,能直接发现默认值或整对象覆盖带来的问题。
导出是最后一个容易漏掉的位置。表格里空单元格与数字零,应保留同样的业务区别;若使用空单元格表示未填写,就需要在列说明中写清含义。导出转换不能先把 NULL 变成 0,再指望使用者从文件里还原“没填”。
这次代码修改覆盖了新增可空字段及相关幂等、修订、导出路径。修改已合入代码基线,生产部署尚未核实。上线后还应分别用新旧客户端检查一次保存和导出,确认运行中的版本遵守相同契约。
新增数量字段时,我会先把缺省、NULL 和 0 的含义写下来,再沿着输入、保存、统计、导出逐段核对。字段类型并不难选,难的是让每一层保留同一份信息。