SQLite json_patch 的数组边界:只想加一个标签,为什么旧列表整个被换掉

昨天 3阅读

配置页面允许用户修改颜色、补一个标签、清空备注。后端把这一小段JSON交给json_patch,颜色更新正确,原来的两个标签却只剩下新标签,备注字段更是彻底消失。函数名称里的patch容易让人联想到逐项补丁,但它采用的是JSON Merge Patch规则,数组和对象的合并方式并不相同。

本例在Linux、CPython 3.12.14与SQLite 3.53.1实跑。SQLite需要提供JSON函数;标准构建从3.38.0起默认包含它们。保存程序为demo.py,运行python3 demo.py。数据仅放在内存连接中,参数使用占位符传入,不拼接成SQL语句,也不会改动磁盘数据库。

SQLite json_patch 的数组边界:只想加一个标签,为什么旧列表整个被换掉

AI生成的概念示意图:整只小托盘的更换表示数组作为一个整体处理,淡色圆片表示移除字段;不是数据库结构图。

完整程序与本次实跑

完整可运行程序

import json
import sqlite3

base = {"theme": {"color": "blue", "size": 2},
        "tags": ["draft", "local"], "note": "keep"}
patch = {"theme": {"color": "green"}, "tags": ["ready"], "note": None}
con = sqlite3.connect(":memory:")
try:
    def merge(target, change):
        text, = con.execute("SELECT json_patch(?, ?)",
                            (json.dumps(target), json.dumps(change))).fetchone()
        return json.loads(text)

    result = merge(base, patch)
    assert result == {"theme": {"color": "green", "size": 2}, "tags": ["ready"]}
    print("merged:", json.dumps(result, sort_keys=True))
    print("original tags:", base["tags"])
    print("null deletes note:", "note" not in result)
    replacement = merge(base, ["only"])
    assert replacement == ["only"]
    print("root array patch:", replacement)
    stored_null, = con.execute("SELECT json_set(?, '$.note', NULL)",
                               (json.dumps(base),)).fetchone()
    assert json.loads(stored_null)["note"] is None
    print("json_set keeps null field:", "note" in json.loads(stored_null))
finally:
    con.close()

本次实际输出(以下为结果,不是程序)

merged: {"tags": ["ready"], "theme": {"color": "green", "size": 2}}
original tags: ['draft', 'local']
null deletes note: True
root array patch: ['only']
json_set keeps null field: True

先检查对象里什么被保留

merged里theme仍有size,color则变成green。补丁中theme的值仍是对象,因此合并会进入这个对象,按成员继续处理。没有出现在补丁里的size不会凭空消失。这个结果适合局部修改具有稳定字段名的配置,例如显示主题、分页设置或通知选项。

程序把结果解码成Python对象后断言其结构,输出时排序键只为方便阅读。不要用输出文本的成员顺序判断合并是否正确;JSON对象的字段顺序不是这里的业务条件。真正需要核对的是字段存在性、值的类型以及嵌套层级。

数组没有逐元素合并的默认身份

tags最终只有ready,原来的draft和local都没有保留。Merge Patch把整个数组当作一个值,新数组替换旧数组,不会把同一位置当作同一个对象,也不会按标签文字去重后追加。即使数组里是带id的对象,这个函数也不会自行把id识别为合并键。

如果需求确实是追加标签,应另行定义追加操作,或者读取并验证旧数组后构造目标数组,再在合适的事务条件下更新。直接把客户端提交的单个新标签包成数组,只是在请求“最终数组就是这一项”。更新协议要表达业务动作,不能依赖函数替调用方猜意图。

补丁里的null表示删除成员

null deletes note为True,因为对象补丁把note设为null时,成员会被移除。它不是保留note并将内容清空。后面的json_set对照保留了该字段,值为JSON null,说明“字段缺失”和“字段存在但为空”可以得到两种不同结构。

如果下游用字段缺失表示继承默认值、用null表示明确禁用,这个区别会改变业务结果。需要保存显式null的接口,不适合直接把所有用户输入解释成Merge Patch;可以改用具有独立操作类型的协议,并分别测试删除与置空。

根值也能被整体替换

root array patch得到only组成的数组,原来的根对象完全被替换。这与嵌套数组的整体处理相呼应:当补丁本身不是对象时,结果就是补丁值。假如配置必须始终是对象,应用要在执行前检查根类型,执行后也要校验完整配置约束。

original tags仍打印原列表,原因是本例向SQL传入JSON文本并接回新结果,没有原地改写Python字典。若结果之后需要落库,应当保存返回值;仅调用SELECT而不处理结果,不会自动更新某条表记录。本文用只读表达式特意把计算结果与持久化分开。

把补丁验收写成结构问题

上线前至少准备四组输入:对象只改一个子字段、数组缩短、成员置null以及根类型改变。再补充不合法JSON和不满足业务模式的结果。数据库接受JSON语法,只证明输入能被解析,不证明配置能被应用正常使用。

多客户端编辑同一份配置时,还要另行处理版本冲突。这个示例只解释一次确定输入的合并规则,没有提供并发控制或历史恢复功能。先把数组与null的合同写清,再讨论保存流程,才能避免一次看似很小的修改删除用户原有配置。

参考资料

官方资料核验于2026年10月2日;上述输出来自本次固定输入实跑,退出码为0。

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