fix(A13): 自引用外键级联(删除/置空/更新/预检四条路径全部生效)
缺陷(PLAN §5 #33,实测确认):
MemoryEngine 与 AriaEngine 的级联实现里都有 `if (refTableName === tableName) continue;`
—— 自引用外键被整体跳过,四条路径全部失效:
- ON DELETE CASCADE:`DELETE root` 只删 root,子树 a/b/c 全部残留,
且 parent_id 指向已删除的行。父行已不在 → 这些行**之后再也无法通过级联清理**
(永久悬挂,静默数据不一致);
- ON DELETE SET NULL:子行的 parent_id 保持旧值(等于什么都没做);
- ON UPDATE CASCADE:`UPDATE node SET id='root2'` 后子行仍指向 'root'(悬挂);
- ON DELETE/UPDATE RESTRICT 预检:不检查自引用,约束形同虚设。
两个引擎的跳过条件逐字相同,因此缺陷是同步的(跨引擎一致地错)。
修法:
1. 删除全部 6 处 `refTableName === tableName)continue`(memory 3 + aria 3)。
2. 自引用带来的两个实现约束,已在注释中写明:
- **先收集引用者再处理**:自引用时遍历的正是同一个 Map,边遍历边删会让
Map 迭代器跳过条目(memory 侧改为先收集 pk 数组);
- **先递归子树再删父行**:否则删掉父行后子行的 parent_id 再也匹配不上。
Aria 侧本就通过 `getAllRows`(cloneRow 副本)收集,天然满足第一条。
3. 级联写入复用 B-1 的 `validatePartial`(上一提交已做),保持一致。
关于 A14(`ON UPDATE CASCADE` 传递链)——**审计结论有误,实测正常**:
审计记录为"A→B→C 链改 A 主键后 C 悬空",但 C 引用的是 **b.id**(未变),
因此 C 不需要更新,"悬空"的推断不成立。本提交的用例把这一结论固化,
并按真正的悬空场景(改 b.id → C 必须跟着更新)补了断言,
避免将来有人按那份错误结论去"修"一个不存在的问题。
验证:新增 tests/v080-foreign-key.test.ts(4 引擎 × 8 项,共 32 断言),
含"删兄弟分支时只清自己子树""RESTRICT 阻断时三张表都不得变化"等边界;
并做**变异验证**:重新插入一处 skip 后 3 项立即失败,恢复后全绿。
全量 87 套件 / 1729 测试通过;typecheck、lint、build 零错误;dist 已重建。
This commit is contained in:
Vendored
+75
-26
@@ -1735,8 +1735,13 @@
|
||||
*/
|
||||
checkUpdateRestrict(tableName, oldPk) {
|
||||
for (const [refTableName, refSchema] of this.schemas) {
|
||||
if (refTableName === tableName)
|
||||
continue;
|
||||
// v0.8.0(A13):**不再跳过自引用外键**(refTableName === tableName)。
|
||||
//
|
||||
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`)
|
||||
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
|
||||
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
|
||||
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
|
||||
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
|
||||
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
|
||||
if (!colDef.references || !colDef.onUpdate)
|
||||
continue;
|
||||
@@ -1772,8 +1777,13 @@
|
||||
*/
|
||||
async applyUpdateCascade(tableName, oldPk, newPk) {
|
||||
for (const [refTableName, refSchema] of this.schemas) {
|
||||
if (refTableName === tableName)
|
||||
continue;
|
||||
// v0.8.0(A13):**不再跳过自引用外键**(refTableName === tableName)。
|
||||
//
|
||||
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`)
|
||||
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
|
||||
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
|
||||
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
|
||||
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
|
||||
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
|
||||
if (!colDef.references || !colDef.onUpdate)
|
||||
continue;
|
||||
@@ -1855,8 +1865,13 @@
|
||||
return;
|
||||
visited.add(visitKey);
|
||||
for (const [refTableName, refSchema] of this.schemas) {
|
||||
if (refTableName === tableName)
|
||||
continue;
|
||||
// v0.8.0(A13):**不再跳过自引用外键**(refTableName === tableName)。
|
||||
//
|
||||
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`)
|
||||
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
|
||||
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
|
||||
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
|
||||
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
|
||||
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
|
||||
if (!colDef.references || !colDef.onDelete)
|
||||
continue;
|
||||
@@ -2188,8 +2203,14 @@
|
||||
visited.add(visitKey);
|
||||
let totalCascade = 0;
|
||||
for (const [refTableName, refSchema] of this.schemas) {
|
||||
if (refTableName === tableName)
|
||||
continue;
|
||||
// v0.8.0(A13):**不再跳过自引用外键**(refTableName === tableName)。
|
||||
//
|
||||
// 此前这里 `continue`,于是 `parent_id REFERENCES node(id)` 这种树形自引用
|
||||
// 完全不做级联。实测(本提交的用例锁定):
|
||||
// INSERT node: root <- a <- b <- c
|
||||
// DELETE root → 只删掉 root,a/b/c 全部残留且 parent_id 指向已删除的行
|
||||
// (且因为父行已删,它们之后**再也无法通过级联清理** —— 永久悬挂)
|
||||
// 这是"删除留下悬挂引用"的静默数据不一致,比报错更糟。
|
||||
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
|
||||
if (!colDef.references || !colDef.onDelete)
|
||||
continue;
|
||||
@@ -2199,32 +2220,39 @@
|
||||
const refTableData = this.tables.get(refTableName);
|
||||
if (!refTableData)
|
||||
continue;
|
||||
// 查找所有引用此主键的行
|
||||
const toDelete = [];
|
||||
// 查找所有引用此主键的行。
|
||||
//
|
||||
// v0.8.0(A13):**先完整收集再处理**。自引用场景下 `refTableData`
|
||||
// 与当前遍历的表是同一个 Map,边遍历边删除会跳过条目(Map 迭代器
|
||||
// 对已删除键的行为取决于删除位置)。收集成数组后处理即与迭代解耦。
|
||||
const referrers = [];
|
||||
for (const [refPk, refRow] of refTableData) {
|
||||
if (String(refRow[colName]) === pkValue) {
|
||||
toDelete.push(refPk);
|
||||
}
|
||||
if (String(refRow[colName]) === pkValue)
|
||||
referrers.push(refPk);
|
||||
}
|
||||
// RESTRICT: 存在引用行时禁止删除
|
||||
if (colDef.onDelete === 'RESTRICT' && toDelete.length > 0) {
|
||||
if (colDef.onDelete === 'RESTRICT' && referrers.length > 0) {
|
||||
throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`, 'FOREIGN_KEY_VIOLATION');
|
||||
}
|
||||
if (colDef.onDelete === 'CASCADE') {
|
||||
// 递归级联
|
||||
for (const refPk of toDelete) {
|
||||
for (const refPk of referrers) {
|
||||
const refRow = refTableData.get(refPk);
|
||||
if (refRow) {
|
||||
// v0.3.3: 级联删除前清理索引条目
|
||||
this.removeIndexEntries(refTableName, refRow, refPk);
|
||||
// v0.8.0(A13):自引用时**先递归再删自己** —— 子树必须先被清掉,
|
||||
// 否则删掉父行后子行的 parent_id 就再也匹配不上(悬挂)。
|
||||
totalCascade += await this.cascadeDelete(refTableName, refPk, refRow, visited);
|
||||
}
|
||||
refTableData.delete(refPk);
|
||||
totalCascade++;
|
||||
if (refTableData.has(refPk)) {
|
||||
refTableData.delete(refPk);
|
||||
totalCascade++;
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (colDef.onDelete === 'SET NULL') {
|
||||
for (const refPk of toDelete) {
|
||||
for (const refPk of referrers) {
|
||||
const refRow = refTableData.get(refPk);
|
||||
if (refRow) {
|
||||
// v0.6.3-fix: 复用 removeIndexEntries 清理旧值索引 —— 此前手动
|
||||
@@ -8791,8 +8819,13 @@
|
||||
*/
|
||||
async checkForeignKeyUpdateRestrict(tableName, oldPk, _newPk) {
|
||||
for (const [refTableName, refSchema] of this.schemas) {
|
||||
if (refTableName === tableName)
|
||||
continue;
|
||||
// v0.8.0(A13):**不再跳过自引用外键**(refTableName === tableName)。
|
||||
//
|
||||
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`)
|
||||
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
|
||||
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
|
||||
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
|
||||
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
|
||||
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
|
||||
if (!colDef.references || !colDef.onUpdate)
|
||||
continue;
|
||||
@@ -8827,8 +8860,13 @@
|
||||
// 已在两阶段 update 预检(阶段 1b)覆盖 RESTRICT 与 SET NULL+required,
|
||||
// 此处任何修改前重复全表扫描纯属浪费。直接执行 CASCADE / SET NULL。
|
||||
for (const [refTableName, refSchema] of this.schemas) {
|
||||
if (refTableName === tableName)
|
||||
continue;
|
||||
// v0.8.0(A13):**不再跳过自引用外键**(refTableName === tableName)。
|
||||
//
|
||||
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`)
|
||||
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
|
||||
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
|
||||
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
|
||||
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
|
||||
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
|
||||
if (!colDef.references || !colDef.onUpdate)
|
||||
continue;
|
||||
@@ -8939,8 +8977,13 @@
|
||||
return;
|
||||
visited.add(visitKey);
|
||||
for (const [refTableName, refSchema] of this.schemas) {
|
||||
if (refTableName === tableName)
|
||||
continue;
|
||||
// v0.8.0(A13):**不再跳过自引用外键**(refTableName === tableName)。
|
||||
//
|
||||
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`)
|
||||
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
|
||||
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
|
||||
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
|
||||
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
|
||||
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
|
||||
if (!colDef.references || !colDef.onDelete)
|
||||
continue;
|
||||
@@ -8980,14 +9023,20 @@
|
||||
return 0;
|
||||
visited.add(visitKey);
|
||||
for (const [refTableName, refSchema] of this.schemas) {
|
||||
if (refTableName === tableName)
|
||||
continue;
|
||||
// v0.8.0(A13):**不再跳过自引用外键**(refTableName === tableName)。
|
||||
//
|
||||
// 此前这里 `continue`:`parent_id REFERENCES node(id)` 的树形自引用完全不做
|
||||
// 级联 → `DELETE root` 只删 root,子树全部残留且 parent_id 指向已删除行
|
||||
//(父行已不在,之后再也无法通过级联清理 —— 永久悬挂)。
|
||||
// 与 MemoryEngine 的修复同源(两个引擎此前的跳过条件逐字相同)。
|
||||
for (const [colName, colDef] of Object.entries(refSchema.columns)) {
|
||||
if (!colDef.references || !colDef.onDelete)
|
||||
continue;
|
||||
const [refTable] = colDef.references.split('.');
|
||||
if (refTable !== tableName)
|
||||
continue;
|
||||
// 自引用场景下 `getAllRows` 返回的是当前表的快照副本(cloneRow),
|
||||
// 因此循环内的删除不会改变 `matched` —— 这正是这里能安全递归的原因。
|
||||
const refRows = await this.getAllRows(refTableName);
|
||||
const matched = refRows.filter((r) => String(r[colName]) === pkValue);
|
||||
if (colDef.onDelete === 'RESTRICT' && matched.length > 0) {
|
||||
|
||||
Reference in New Issue
Block a user