开始前,会这些就够了
- 能读懂 INSERT、UPDATE 与主键约束。
- 知道子表可以通过外键关联父表。
- Python 3.10+,SQLite 3.24.0+;仅标准库,使用全新内存数据库。
你想把一个资料夹改名,保存后新名称出现了,编号也没变,可里面的明细全部消失。检查接口,它返回成功,影响行数是一。问题可能藏在“有就替换”的具体写法:SQLite 的 INSERT OR REPLACE 遇到主键冲突,并不等同于只修改原行。
本实验让父记录始终从编号一开始,关联一条子记录,并给父记录加一段应该保留的备注。四种保存路径各用一份全新内存库,对比完整前后状态,产出一张能看见关联与遗漏字段的变化表。
把例子带到自己的电脑上
下载完整练习包
需要 Python 3.10 或更新版本,无第三方依赖;制作时实测 Python 3.14.0;内置 SQLite 需 3.24.0 或更新版本。使用标准库,不需要安装第三方包。下载 ZIP 后选择“全部提取”,进入能看到 README.md 的文件夹。
下载 ZIP · 5 个文件包里有什么
- replace_versus_upsert.py:完整示例
- README.md:环境、范围与独立练习
- observed.stdout.txt:制作时的实际输出
- verification.json:源码与产物验证记录
- SHA256SUMS.txt:文件内容摘要
在这个文件夹打开 PowerShell,逐行运行
python --version
python -B replace_versus_upsert.py解压到独立练习文件夹,在含源文件的目录打开终端运行。程序会在当前目录写入教学报告;先预测结果,再查看当次输出。四个独立的真实 SQLite 内存数据库,启用外键并核对级联删除与两种 UPSERT 分支;不代表其他数据库或线上业务。
找不到命令或文件时,先按 README 检查环境和所在目录。文中标出的阅读时间只估算文章篇幅,安装环境和动手练习可按自己的节奏进行。
先写出允许改变的内容,再选择保存语句
假设本次操作只允许把 old-name 改成 new-name。编号一应保留,备注 keep-note 应保留,子记录十仍应指向它。这三项都是验收要求;只查询名称是否更新,会把最关键的副作用藏起来。
父表有 id、name、note 三列,备注的默认值是 default-note。子表通过 parent_id 关联父表,并明确配置 ON DELETE CASCADE:父行被删除时,依赖它的子行也删除。每个案例都启用并核对外键检查,不比较开关不同造成的结果。
程序把四种方式放到相同初态,各自执行一次,再保存 before 与 after。每份内存库结束后关闭,下一组重新建库。这样前一组删掉的子行不会让后一组失去比较基础,也不会将连续操作误写成独立对照。
REPLACE 同一编号,仍然可能经历删除再插入
第一组执行 INSERT OR REPLACE,只提供编号一与新名称。编号一已经存在,触发主键冲突;SQLite 的 REPLACE 策略会先删除造成该冲突的既有行,再继续插入。它不是把这条语句自动改写成只更新 name 的 UPDATE。
最终父表里又有编号一,名称也正确,但 child 已经为空。旧父行删除时发生了我们明确配置的级联删除;后来再插入相同编号,不会把已经删除的子记录自动恢复。最终编号相同,无法独立证明中间没有发生删除。
这个结论依赖实际表的关联动作。若换成限制删除的规则,结果可能是失败,而不是删除子行。本篇没有宣称所有 REPLACE 都会删除所有关联数据;它展示的是一份具体且常见配置下,为什么改名接口会产生出乎意料的影响。
没有提供的备注,也不是自动沿用旧值
第一组父记录的备注从 keep-note 变成 default-note。原因与前面相连:执行的是一条新插入路径,语句没有提供 note,于是使用表中声明的默认值。把语句字段少理解成“只修改这几个字段”,在这里就会判断错误。
我们特意把旧备注设成与默认值不同。如果初始备注碰巧就等于默认值,即使经历同样替换,单看最终文字也不容易发现变化。设计验证样本时,应让需要保留的字段具有能识别的原值,而不是全部填空或默认内容。
默认值也不代表业务上正确。它只是建表时安排的填充值,可能不适合一次已有记录的更新。选择保存策略前,除了主键和必填字段,还应列出哪些列本次未提供,以及它们应保留、清空还是采用默认值。
需要存在则更新时,把冲突分支写清楚
第二组使用 INSERT 后的 ON CONFLICT(id) DO UPDATE,冲突目标明确为 id,更新列表只有 name=excluded.name。excluded.name 表示这次原本准备插入的新名称;发生冲突后,更新分支把它用于现有行。结果是名称变了,备注与子记录都保留。
第三组直接用 UPDATE 改已有行,得到相同的业务状态。这组对照提醒我们:如果操作只允许修改已存在对象,未必需要引入“没有就新增”的路径。选 UPDATE 还是 UPSERT,先由接口是否允许创建决定,而不是因为后者写法看起来更通用。
UPSERT 也不是“遇到任何错误都修好”。冲突目标针对唯一性约束,更新分支只执行写出来的修改;其他约束失败不会因此凭空变成成功。本例没有改父键,没有复杂触发器,也没有比较多条冲突分支,不能扩大成所有 UPSERT 都没有关联副作用。
同一条 UPSERT,没有冲突时会真正新增
第四组仍用相同 UPSERT 语句,但提供不存在的编号二。这次没有进入更新分支,而是多出一条父记录二,它的备注采用 default-note;原编号一的旧名称、旧备注和子记录十保持原样。
这说明只测“已存在”的那一半,不足以验收一个插入或更新接口。两条路径对遗漏字段可能使用不同依据:本例更新分支保留旧备注,新增分支使用默认备注。调用者应知道这两个结果都属于接口承诺。
四组的 foreign_key_check 都为空,包括第一组删掉子行的情况。没有孤儿记录,只证明关联约束没有被破坏;它不能证明用户希望保留的明细还在。约束检查和业务记录保留是不同的验收项,两者都要观察。
运行完整代码,同时检查父表与子表
将下方代码保存为 replace_versus_upsert.py,在同目录运行。程序只创建四份新的内存数据库,正常结束时关闭连接,不接受业务数据库路径。脚本旁的 replace-output 会生成 report.json 与 effects.csv,再次运行时更新同名教学产物。
本次在 Python 3.14.0、SQLite 3.50.4 实际通过。先预测四组的父行、备注与子行,再核对完整列表。replace_same_id 的子表为空且备注变为默认值;upsert_existing 与 update_existing 保留关联;upsert_new 同时保留原行并新增另一行。
脚本还在每次写入后立即执行 SELECT changes(),四组都得到一。这个计数只统计相应语句的直接变化,不把外键动作和 REPLACE 冲突处理的附带变化一并计算。因此返回一行,不能替代对相关表的结果检查。
展开收起代码 · 小实验
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
"""SQLite replacement vs update: fresh in-memory fixtures, no real records."""
from contextlib import closing
from pathlib import Path
import csv
import json
import sqlite3
import sys
def connect():
options = {"isolation_level": None}
if sys.version_info >= (3, 12):
options["autocommit"] = sqlite3.LEGACY_TRANSACTION_CONTROL
db = sqlite3.connect(":memory:", **options)
try:
db.execute("PRAGMA foreign_keys = ON")
assert db.execute("PRAGMA foreign_keys").fetchone() == (1,)
db.execute("CREATE TABLE parent(id INTEGER PRIMARY KEY, name TEXT NOT NULL, note TEXT NOT NULL DEFAULT 'default-note')")
db.execute("CREATE TABLE child(id INTEGER PRIMARY KEY, parent_id INTEGER NOT NULL REFERENCES parent(id) ON DELETE CASCADE)")
db.execute("INSERT INTO parent VALUES (?, ?, ?)", (1, "old-name", "keep-note"))
db.execute("INSERT INTO child VALUES (?, ?)", (10, 1))
return db
except BaseException:
db.close()
raise
def snapshot(db):
return {"parents": [list(r) for r in db.execute("SELECT id, name, note FROM parent ORDER BY id")],
"children": [list(r) for r in db.execute("SELECT id, parent_id FROM child ORDER BY id")],
"foreign_key_violations": [list(r) for r in db.execute("PRAGMA foreign_key_check")]}
def main():
assert sqlite3.sqlite_version_info >= (3, 24, 0), "UPSERT requires SQLite 3.24.0+"
upsert = "INSERT INTO parent(id, name) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name"
cases = [
("replace_same_id", "INSERT OR REPLACE INTO parent(id, name) VALUES (?, ?)", (1, "new-name"),
[[1, "new-name", "default-note"]], []),
("upsert_existing", upsert, (1, "new-name"), [[1, "new-name", "keep-note"]], [[10, 1]]),
("update_existing", "UPDATE parent SET name=? WHERE id=?", ("new-name", 1),
[[1, "new-name", "keep-note"]], [[10, 1]]),
("upsert_new", upsert, (2, "new-name"),
[[1, "old-name", "keep-note"], [2, "new-name", "default-note"]], [[10, 1]]),
]
records = []
for label, statement, parameters, expected_parents, expected_children in cases:
with closing(connect()) as db:
before = snapshot(db)
db.execute(statement, parameters)
direct_changes = db.execute("SELECT changes()").fetchone()[0]
after = snapshot(db)
assert direct_changes == 1
assert after["parents"] == expected_parents, (label, after)
assert after["children"] == expected_children, (label, after)
assert after["foreign_key_violations"] == []
assert db.execute("PRAGMA foreign_keys").fetchone() == (1,)
records.append({"case": label, "sql": statement, "parameters": parameters,
"direct_changes": direct_changes, "before": before, "after": after})
report = {"python": sys.version.split()[0], "sqlite": sqlite3.sqlite_version,
"scope": "four isolated real SQLite in-memory databases, foreign_keys ON throughout; no concurrency or user-defined triggers",
"cases": records}
out = Path(__file__).resolve().parent / "replace-output"
out.mkdir(exist_ok=True)
(out / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
with (out / "effects.csv").open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.DictWriter(file, fieldnames=["case", "direct_changes", "parents", "children", "violations"])
writer.writeheader()
for row in records:
writer.writerow({"case": row["case"], "direct_changes": row["direct_changes"],
"parents": json.dumps(row["after"]["parents"]), "children": json.dumps(row["after"]["children"]),
"violations": json.dumps(row["after"]["foreign_key_violations"])})
print(json.dumps(report, indent=2))
print("PASS: same-ID replacement, cascading children, omitted defaults, upsert branches and direct-change counts")
if __name__ == "__main__":
main()
怎样运行:python -B replace_versus_upsert.py(Python 3.10+,仅标准库;在解压后的脚本目录执行。) 2026-10-15 已用 Python 3.14.0 执行这份代码并通过自带检查;下方是当次输出。四个独立的真实 SQLite 内存数据库,启用外键并核对级联删除与两种 UPSERT 分支;不代表其他数据库或线上业务。
运行后对照结果
{
"python": "3.14.0",
"sqlite": "3.50.4",
"scope": "four isolated real SQLite in-memory databases, foreign_keys ON throughout; no concurrency or user-defined triggers",
"cases": [
{
"case": "replace_same_id",
"sql": "INSERT OR REPLACE INTO parent(id, name) VALUES (?, ?)",
"parameters": [
1,
"new-name"
],
"direct_changes": 1,
"before": {
"parents": [
[
1,
"old-name",
"keep-note"
]
],
"children": [
[
10,
1
]
],
"foreign_key_violations": []
},
"after": {
"parents": [
[
1,
"new-name",
"default-note"
]
],
"children": [],
"foreign_key_violations": []
}
},
{
"case": "upsert_existing",
"sql": "INSERT INTO parent(id, name) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name",
"parameters": [
1,
"new-name"
],
"direct_changes": 1,
"before": {
"parents": [
[
1,
"old-name",
"keep-note"
]
],
"children": [
[
10,
1
]
],
"foreign_key_violations": []
},
"after": {
"parents": [
[
1,
"new-name",
"keep-note"
]
],
"children": [
[
10,
1
]
],
"foreign_key_violations": []
}
},
{
"case": "update_existing",
"sql": "UPDATE parent SET name=? WHERE id=?",
"parameters": [
"new-name",
1
],
"direct_changes": 1,
"before": {
"parents": [
[
1,
"old-name",
"keep-note"
]
],
"children": [
[
10,
1
]
],
"foreign_key_violations": []
},
"after": {
"parents": [
[
1,
"new-name",
"keep-note"
]
],
"children": [
[
10,
1
]
],
"foreign_key_violations": []
}
},
{
"case": "upsert_new",
"sql": "INSERT INTO parent(id, name) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET name=excluded.name",
"parameters": [
2,
"new-name"
],
"direct_changes": 1,
"before": {
"parents": [
[
1,
"old-name",
"keep-note"
]
],
"children": [
[
10,
1
]
],
"foreign_key_violations": []
},
"after": {
"parents": [
[
1,
"old-name",
"keep-note"
],
[
2,
"new-name",
"default-note"
]
],
"children": [
[
10,
1
]
],
"foreign_key_violations": []
}
}
]
}
PASS: same-ID replacement, cascading children, omitted defaults, upsert branches and direct-change counts让保存方法的名字对应真实的数据变化
回到应用时,找到最终送给数据库的 SQL,而不是只看方法叫 save、replace 还是 update。ORM 与数据库方言可能选择不同语句;先确认实际版本、表约束和关联动作,再给方法行为下结论。本文只实际执行 SQLite,没有运行任何 ORM 或其他数据库。
验证应保存输入、SQL、父行与子行的前后快照,再写出与本次业务意图是否一致。若业务确实需要完全替换记录,应主动设计相关数据的保留或删除规则;不能靠把错误隐藏成成功,或临时关闭外键,让不清楚的操作继续。
本实验是顺序的单语句对照,没有并发竞争、进程崩溃或性能计时,也没有安装自定义触发器。它足以说明这四条路径在当前环境留下了什么,不能证明所有替换操作的审计钩子、触发顺序或故障持久性。带着这些边界,下一步检查才有明确目标。
记住这三点
- SQLite REPLACE 遇到唯一性冲突会删除冲突旧行,不能用它代替所有原地更新。
- 相同最终主键、空外键审计和
changes()等于一,都不能独立证明关联明细被保留。 - UPSERT 的新增分支与更新分支应分别验收,遗漏字段也必须有明确政策。
别把例子用过头
- 只检查新名称与编号,没检查备注和子表。
- 把 INSERT 中未提供的字段理解成保留旧值。
- 以为外键审计通过就证明业务数据没有被意外删掉。
自己试一小步
为一次资料修改设计一组对照,怎样证明允许变化的列变了,而其他内容与关联都保留?
- 自拟至少两个父记录、三条子记录和一个额外带默认值的字段,不沿用本文编号与文字。
- 明确本次接口是否允许新增,先写出已存在与不存在两类输入的业务预期。
- 在全新内存库比较替换、明确更新及冲突更新,保留完整前后快照。
- 对照直接变化数、外键审计与实际子记录列表,解释三者分别能证明什么。
做到这些,就算完成
- 没有只提交 PASS 或影响行数。
- 遗漏字段初值与默认值不同,能够观察是否保留。
- 分开核对插入分支与更新分支。
- 不关闭约束掩盖差异,不访问真实业务数据。
查原始资料
想确认一个细节,先看这份资料对应的部分,不必从头读完所有文档。
- SQLite:ON CONFLICT 中的 REPLACE (新标签页)↗
核读 REPLACE 条目及其适用范围:唯一性冲突时删除既有冲突行;并核对不要将其与 INSERT 后的 UPSERT 混为同一机制。
- SQLite:UPSERT 冲突目标与 excluded (新标签页)↗
核读第 2、3、4 节:唯一性冲突目标、DO UPDATE、excluded 值、约束限制与 SQLite 3.24.0 起的支持范围。
- SQLite:外键删除与更新动作 (新标签页)↗
核读 4.3 节 ON DELETE CASCADE,支持删除父行时删除相关子行;同时保留不同关联动作会给出不同结果的范围。
- SQLite:直接修改行数的计数范围 (新标签页)↗
核读直接修改计数排除触发器、外键动作与 REPLACE 附带变化;并核对
changes()SQL 函数包装遵循同样规则。