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:
thzxx
2026-09-15 01:36:29 +08:00
parent 0c5c0b1d20
commit 85f0f170a4
10 changed files with 500 additions and 100 deletions
+73 -24
View File
@@ -1733,8 +1733,13 @@ class MemoryEngine {
*/ */
checkUpdateRestrict(tableName, oldPk) { checkUpdateRestrict(tableName, oldPk) {
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) if (!colDef.references || !colDef.onUpdate)
continue; continue;
@@ -1770,8 +1775,13 @@ class MemoryEngine {
*/ */
async applyUpdateCascade(tableName, oldPk, newPk) { async applyUpdateCascade(tableName, oldPk, newPk) {
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) if (!colDef.references || !colDef.onUpdate)
continue; continue;
@@ -1853,8 +1863,13 @@ class MemoryEngine {
return; return;
visited.add(visitKey); visited.add(visitKey);
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete) if (!colDef.references || !colDef.onDelete)
continue; continue;
@@ -2186,8 +2201,14 @@ class MemoryEngine {
visited.add(visitKey); visited.add(visitKey);
let totalCascade = 0; let totalCascade = 0;
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是 `parent_id REFERENCES node(id)` 这种树形自引用
// 完全不做级联。实测(本提交的用例锁定):
// INSERT node: root <- a <- b <- c
// DELETE root → 只删掉 roota/b/c 全部残留且 parent_id 指向已删除的行
// (且因为父行已删,它们之后**再也无法通过级联清理** —— 永久悬挂)
// 这是"删除留下悬挂引用"的静默数据不一致,比报错更糟。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete) if (!colDef.references || !colDef.onDelete)
continue; continue;
@@ -2197,32 +2218,39 @@ class MemoryEngine {
const refTableData = this.tables.get(refTableName); const refTableData = this.tables.get(refTableName);
if (!refTableData) if (!refTableData)
continue; continue;
// 查找所有引用此主键的行 // 查找所有引用此主键的行
const toDelete = []; //
// v0.8.0A13):**先完整收集再处理**。自引用场景下 `refTableData`
// 与当前遍历的表是同一个 Map,边遍历边删除会跳过条目(Map 迭代器
// 对已删除键的行为取决于删除位置)。收集成数组后处理即与迭代解耦。
const referrers = [];
for (const [refPk, refRow] of refTableData) { for (const [refPk, refRow] of refTableData) {
if (String(refRow[colName]) === pkValue) { if (String(refRow[colName]) === pkValue)
toDelete.push(refPk); referrers.push(refPk);
}
} }
// RESTRICT: 存在引用行时禁止删除 // 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'); throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`, 'FOREIGN_KEY_VIOLATION');
} }
if (colDef.onDelete === 'CASCADE') { if (colDef.onDelete === 'CASCADE') {
// 递归级联 // 递归级联
for (const refPk of toDelete) { for (const refPk of referrers) {
const refRow = refTableData.get(refPk); const refRow = refTableData.get(refPk);
if (refRow) { if (refRow) {
// v0.3.3: 级联删除前清理索引条目 // v0.3.3: 级联删除前清理索引条目
this.removeIndexEntries(refTableName, refRow, refPk); this.removeIndexEntries(refTableName, refRow, refPk);
// v0.8.0(A13):自引用时**先递归再删自己** —— 子树必须先被清掉,
// 否则删掉父行后子行的 parent_id 就再也匹配不上(悬挂)。
totalCascade += await this.cascadeDelete(refTableName, refPk, refRow, visited); totalCascade += await this.cascadeDelete(refTableName, refPk, refRow, visited);
} }
if (refTableData.has(refPk)) {
refTableData.delete(refPk); refTableData.delete(refPk);
totalCascade++; totalCascade++;
} }
} }
}
else if (colDef.onDelete === 'SET NULL') { else if (colDef.onDelete === 'SET NULL') {
for (const refPk of toDelete) { for (const refPk of referrers) {
const refRow = refTableData.get(refPk); const refRow = refTableData.get(refPk);
if (refRow) { if (refRow) {
// v0.6.3-fix: 复用 removeIndexEntries 清理旧值索引 —— 此前手动 // v0.6.3-fix: 复用 removeIndexEntries 清理旧值索引 —— 此前手动
@@ -8789,8 +8817,13 @@ class AriaEngine {
*/ */
async checkForeignKeyUpdateRestrict(tableName, oldPk, _newPk) { async checkForeignKeyUpdateRestrict(tableName, oldPk, _newPk) {
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) if (!colDef.references || !colDef.onUpdate)
continue; continue;
@@ -8825,8 +8858,13 @@ class AriaEngine {
// 已在两阶段 update 预检(阶段 1b)覆盖 RESTRICT 与 SET NULL+required // 已在两阶段 update 预检(阶段 1b)覆盖 RESTRICT 与 SET NULL+required
// 此处任何修改前重复全表扫描纯属浪费。直接执行 CASCADE / SET NULL。 // 此处任何修改前重复全表扫描纯属浪费。直接执行 CASCADE / SET NULL。
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) if (!colDef.references || !colDef.onUpdate)
continue; continue;
@@ -8937,8 +8975,13 @@ class AriaEngine {
return; return;
visited.add(visitKey); visited.add(visitKey);
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete) if (!colDef.references || !colDef.onDelete)
continue; continue;
@@ -8978,14 +9021,20 @@ class AriaEngine {
return 0; return 0;
visited.add(visitKey); visited.add(visitKey);
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue``parent_id REFERENCES node(id)` 的树形自引用完全不做
// 级联 → `DELETE root` 只删 root,子树全部残留且 parent_id 指向已删除行
//(父行已不在,之后再也无法通过级联清理 —— 永久悬挂)。
// 与 MemoryEngine 的修复同源(两个引擎此前的跳过条件逐字相同)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete) if (!colDef.references || !colDef.onDelete)
continue; continue;
const [refTable] = colDef.references.split('.'); const [refTable] = colDef.references.split('.');
if (refTable !== tableName) if (refTable !== tableName)
continue; continue;
// 自引用场景下 `getAllRows` 返回的是当前表的快照副本(cloneRow),
// 因此循环内的删除不会改变 `matched` —— 这正是这里能安全递归的原因。
const refRows = await this.getAllRows(refTableName); const refRows = await this.getAllRows(refTableName);
const matched = refRows.filter((r) => String(r[colName]) === pkValue); const matched = refRows.filter((r) => String(r[colName]) === pkValue);
if (colDef.onDelete === 'RESTRICT' && matched.length > 0) { if (colDef.onDelete === 'RESTRICT' && matched.length > 0) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+73 -24
View File
@@ -1729,8 +1729,13 @@ class MemoryEngine {
*/ */
checkUpdateRestrict(tableName, oldPk) { checkUpdateRestrict(tableName, oldPk) {
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) if (!colDef.references || !colDef.onUpdate)
continue; continue;
@@ -1766,8 +1771,13 @@ class MemoryEngine {
*/ */
async applyUpdateCascade(tableName, oldPk, newPk) { async applyUpdateCascade(tableName, oldPk, newPk) {
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) if (!colDef.references || !colDef.onUpdate)
continue; continue;
@@ -1849,8 +1859,13 @@ class MemoryEngine {
return; return;
visited.add(visitKey); visited.add(visitKey);
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete) if (!colDef.references || !colDef.onDelete)
continue; continue;
@@ -2182,8 +2197,14 @@ class MemoryEngine {
visited.add(visitKey); visited.add(visitKey);
let totalCascade = 0; let totalCascade = 0;
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是 `parent_id REFERENCES node(id)` 这种树形自引用
// 完全不做级联。实测(本提交的用例锁定):
// INSERT node: root <- a <- b <- c
// DELETE root → 只删掉 roota/b/c 全部残留且 parent_id 指向已删除的行
// (且因为父行已删,它们之后**再也无法通过级联清理** —— 永久悬挂)
// 这是"删除留下悬挂引用"的静默数据不一致,比报错更糟。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete) if (!colDef.references || !colDef.onDelete)
continue; continue;
@@ -2193,32 +2214,39 @@ class MemoryEngine {
const refTableData = this.tables.get(refTableName); const refTableData = this.tables.get(refTableName);
if (!refTableData) if (!refTableData)
continue; continue;
// 查找所有引用此主键的行 // 查找所有引用此主键的行
const toDelete = []; //
// v0.8.0A13):**先完整收集再处理**。自引用场景下 `refTableData`
// 与当前遍历的表是同一个 Map,边遍历边删除会跳过条目(Map 迭代器
// 对已删除键的行为取决于删除位置)。收集成数组后处理即与迭代解耦。
const referrers = [];
for (const [refPk, refRow] of refTableData) { for (const [refPk, refRow] of refTableData) {
if (String(refRow[colName]) === pkValue) { if (String(refRow[colName]) === pkValue)
toDelete.push(refPk); referrers.push(refPk);
}
} }
// RESTRICT: 存在引用行时禁止删除 // 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'); throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`, 'FOREIGN_KEY_VIOLATION');
} }
if (colDef.onDelete === 'CASCADE') { if (colDef.onDelete === 'CASCADE') {
// 递归级联 // 递归级联
for (const refPk of toDelete) { for (const refPk of referrers) {
const refRow = refTableData.get(refPk); const refRow = refTableData.get(refPk);
if (refRow) { if (refRow) {
// v0.3.3: 级联删除前清理索引条目 // v0.3.3: 级联删除前清理索引条目
this.removeIndexEntries(refTableName, refRow, refPk); this.removeIndexEntries(refTableName, refRow, refPk);
// v0.8.0(A13):自引用时**先递归再删自己** —— 子树必须先被清掉,
// 否则删掉父行后子行的 parent_id 就再也匹配不上(悬挂)。
totalCascade += await this.cascadeDelete(refTableName, refPk, refRow, visited); totalCascade += await this.cascadeDelete(refTableName, refPk, refRow, visited);
} }
if (refTableData.has(refPk)) {
refTableData.delete(refPk); refTableData.delete(refPk);
totalCascade++; totalCascade++;
} }
} }
}
else if (colDef.onDelete === 'SET NULL') { else if (colDef.onDelete === 'SET NULL') {
for (const refPk of toDelete) { for (const refPk of referrers) {
const refRow = refTableData.get(refPk); const refRow = refTableData.get(refPk);
if (refRow) { if (refRow) {
// v0.6.3-fix: 复用 removeIndexEntries 清理旧值索引 —— 此前手动 // v0.6.3-fix: 复用 removeIndexEntries 清理旧值索引 —— 此前手动
@@ -8785,8 +8813,13 @@ class AriaEngine {
*/ */
async checkForeignKeyUpdateRestrict(tableName, oldPk, _newPk) { async checkForeignKeyUpdateRestrict(tableName, oldPk, _newPk) {
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) if (!colDef.references || !colDef.onUpdate)
continue; continue;
@@ -8821,8 +8854,13 @@ class AriaEngine {
// 已在两阶段 update 预检(阶段 1b)覆盖 RESTRICT 与 SET NULL+required // 已在两阶段 update 预检(阶段 1b)覆盖 RESTRICT 与 SET NULL+required
// 此处任何修改前重复全表扫描纯属浪费。直接执行 CASCADE / SET NULL。 // 此处任何修改前重复全表扫描纯属浪费。直接执行 CASCADE / SET NULL。
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) if (!colDef.references || !colDef.onUpdate)
continue; continue;
@@ -8933,8 +8971,13 @@ class AriaEngine {
return; return;
visited.add(visitKey); visited.add(visitKey);
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete) if (!colDef.references || !colDef.onDelete)
continue; continue;
@@ -8974,14 +9017,20 @@ class AriaEngine {
return 0; return 0;
visited.add(visitKey); visited.add(visitKey);
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue``parent_id REFERENCES node(id)` 的树形自引用完全不做
// 级联 → `DELETE root` 只删 root,子树全部残留且 parent_id 指向已删除行
//(父行已不在,之后再也无法通过级联清理 —— 永久悬挂)。
// 与 MemoryEngine 的修复同源(两个引擎此前的跳过条件逐字相同)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete) if (!colDef.references || !colDef.onDelete)
continue; continue;
const [refTable] = colDef.references.split('.'); const [refTable] = colDef.references.split('.');
if (refTable !== tableName) if (refTable !== tableName)
continue; continue;
// 自引用场景下 `getAllRows` 返回的是当前表的快照副本(cloneRow),
// 因此循环内的删除不会改变 `matched` —— 这正是这里能安全递归的原因。
const refRows = await this.getAllRows(refTableName); const refRows = await this.getAllRows(refTableName);
const matched = refRows.filter((r) => String(r[colName]) === pkValue); const matched = refRows.filter((r) => String(r[colName]) === pkValue);
if (colDef.onDelete === 'RESTRICT' && matched.length > 0) { if (colDef.onDelete === 'RESTRICT' && matched.length > 0) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+73 -24
View File
@@ -1735,8 +1735,13 @@
*/ */
checkUpdateRestrict(tableName, oldPk) { checkUpdateRestrict(tableName, oldPk) {
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) if (!colDef.references || !colDef.onUpdate)
continue; continue;
@@ -1772,8 +1777,13 @@
*/ */
async applyUpdateCascade(tableName, oldPk, newPk) { async applyUpdateCascade(tableName, oldPk, newPk) {
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) if (!colDef.references || !colDef.onUpdate)
continue; continue;
@@ -1855,8 +1865,13 @@
return; return;
visited.add(visitKey); visited.add(visitKey);
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete) if (!colDef.references || !colDef.onDelete)
continue; continue;
@@ -2188,8 +2203,14 @@
visited.add(visitKey); visited.add(visitKey);
let totalCascade = 0; let totalCascade = 0;
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是 `parent_id REFERENCES node(id)` 这种树形自引用
// 完全不做级联。实测(本提交的用例锁定):
// INSERT node: root <- a <- b <- c
// DELETE root → 只删掉 roota/b/c 全部残留且 parent_id 指向已删除的行
// (且因为父行已删,它们之后**再也无法通过级联清理** —— 永久悬挂)
// 这是"删除留下悬挂引用"的静默数据不一致,比报错更糟。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete) if (!colDef.references || !colDef.onDelete)
continue; continue;
@@ -2199,32 +2220,39 @@
const refTableData = this.tables.get(refTableName); const refTableData = this.tables.get(refTableName);
if (!refTableData) if (!refTableData)
continue; continue;
// 查找所有引用此主键的行 // 查找所有引用此主键的行
const toDelete = []; //
// v0.8.0A13):**先完整收集再处理**。自引用场景下 `refTableData`
// 与当前遍历的表是同一个 Map,边遍历边删除会跳过条目(Map 迭代器
// 对已删除键的行为取决于删除位置)。收集成数组后处理即与迭代解耦。
const referrers = [];
for (const [refPk, refRow] of refTableData) { for (const [refPk, refRow] of refTableData) {
if (String(refRow[colName]) === pkValue) { if (String(refRow[colName]) === pkValue)
toDelete.push(refPk); referrers.push(refPk);
}
} }
// RESTRICT: 存在引用行时禁止删除 // 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'); throw new DatabaseError(`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`, 'FOREIGN_KEY_VIOLATION');
} }
if (colDef.onDelete === 'CASCADE') { if (colDef.onDelete === 'CASCADE') {
// 递归级联 // 递归级联
for (const refPk of toDelete) { for (const refPk of referrers) {
const refRow = refTableData.get(refPk); const refRow = refTableData.get(refPk);
if (refRow) { if (refRow) {
// v0.3.3: 级联删除前清理索引条目 // v0.3.3: 级联删除前清理索引条目
this.removeIndexEntries(refTableName, refRow, refPk); this.removeIndexEntries(refTableName, refRow, refPk);
// v0.8.0(A13):自引用时**先递归再删自己** —— 子树必须先被清掉,
// 否则删掉父行后子行的 parent_id 就再也匹配不上(悬挂)。
totalCascade += await this.cascadeDelete(refTableName, refPk, refRow, visited); totalCascade += await this.cascadeDelete(refTableName, refPk, refRow, visited);
} }
if (refTableData.has(refPk)) {
refTableData.delete(refPk); refTableData.delete(refPk);
totalCascade++; totalCascade++;
} }
} }
}
else if (colDef.onDelete === 'SET NULL') { else if (colDef.onDelete === 'SET NULL') {
for (const refPk of toDelete) { for (const refPk of referrers) {
const refRow = refTableData.get(refPk); const refRow = refTableData.get(refPk);
if (refRow) { if (refRow) {
// v0.6.3-fix: 复用 removeIndexEntries 清理旧值索引 —— 此前手动 // v0.6.3-fix: 复用 removeIndexEntries 清理旧值索引 —— 此前手动
@@ -8791,8 +8819,13 @@
*/ */
async checkForeignKeyUpdateRestrict(tableName, oldPk, _newPk) { async checkForeignKeyUpdateRestrict(tableName, oldPk, _newPk) {
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) if (!colDef.references || !colDef.onUpdate)
continue; continue;
@@ -8827,8 +8860,13 @@
// 已在两阶段 update 预检(阶段 1b)覆盖 RESTRICT 与 SET NULL+required // 已在两阶段 update 预检(阶段 1b)覆盖 RESTRICT 与 SET NULL+required
// 此处任何修改前重复全表扫描纯属浪费。直接执行 CASCADE / SET NULL。 // 此处任何修改前重复全表扫描纯属浪费。直接执行 CASCADE / SET NULL。
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) if (!colDef.references || !colDef.onUpdate)
continue; continue;
@@ -8939,8 +8977,13 @@
return; return;
visited.add(visitKey); visited.add(visitKey);
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete) if (!colDef.references || !colDef.onDelete)
continue; continue;
@@ -8980,14 +9023,20 @@
return 0; return 0;
visited.add(visitKey); visited.add(visitKey);
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
continue; //
// 此前这里 `continue``parent_id REFERENCES node(id)` 的树形自引用完全不做
// 级联 → `DELETE root` 只删 root,子树全部残留且 parent_id 指向已删除行
//(父行已不在,之后再也无法通过级联清理 —— 永久悬挂)。
// 与 MemoryEngine 的修复同源(两个引擎此前的跳过条件逐字相同)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete) if (!colDef.references || !colDef.onDelete)
continue; continue;
const [refTable] = colDef.references.split('.'); const [refTable] = colDef.references.split('.');
if (refTable !== tableName) if (refTable !== tableName)
continue; continue;
// 自引用场景下 `getAllRows` 返回的是当前表的快照副本(cloneRow),
// 因此循环内的删除不会改变 `matched` —— 这正是这里能安全递归的原因。
const refRows = await this.getAllRows(refTableName); const refRows = await this.getAllRows(refTableName);
const matched = refRows.filter((r) => String(r[colName]) === pkValue); const matched = refRows.filter((r) => String(r[colName]) === pkValue);
if (colDef.onDelete === 'RESTRICT' && matched.length > 0) { if (colDef.onDelete === 'RESTRICT' && matched.length > 0) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+29 -4
View File
@@ -975,7 +975,13 @@ export class AriaEngine implements IStorageEngine {
*/ */
private async checkForeignKeyUpdateRestrict(tableName: string, oldPk: string, _newPk: string): Promise<void> { private async checkForeignKeyUpdateRestrict(tableName: string, oldPk: string, _newPk: string): Promise<void> {
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) continue; // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
//
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) continue; if (!colDef.references || !colDef.onUpdate) continue;
const [refTable] = colDef.references.split('.'); const [refTable] = colDef.references.split('.');
@@ -1018,7 +1024,13 @@ export class AriaEngine implements IStorageEngine {
// 已在两阶段 update 预检(阶段 1b)覆盖 RESTRICT 与 SET NULL+required // 已在两阶段 update 预检(阶段 1b)覆盖 RESTRICT 与 SET NULL+required
// 此处任何修改前重复全表扫描纯属浪费。直接执行 CASCADE / SET NULL。 // 此处任何修改前重复全表扫描纯属浪费。直接执行 CASCADE / SET NULL。
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) continue; // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
//
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) continue; if (!colDef.references || !colDef.onUpdate) continue;
const [refTable] = colDef.references.split('.'); const [refTable] = colDef.references.split('.');
@@ -1133,7 +1145,13 @@ export class AriaEngine implements IStorageEngine {
visited.add(visitKey); visited.add(visitKey);
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) continue; // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
//
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete) continue; if (!colDef.references || !colDef.onDelete) continue;
const [refTable] = colDef.references.split('.'); const [refTable] = colDef.references.split('.');
@@ -1183,12 +1201,19 @@ export class AriaEngine implements IStorageEngine {
visited.add(visitKey); visited.add(visitKey);
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) continue; // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
//
// 此前这里 `continue``parent_id REFERENCES node(id)` 的树形自引用完全不做
// 级联 → `DELETE root` 只删 root,子树全部残留且 parent_id 指向已删除行
//(父行已不在,之后再也无法通过级联清理 —— 永久悬挂)。
// 与 MemoryEngine 的修复同源(两个引擎此前的跳过条件逐字相同)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete) continue; if (!colDef.references || !colDef.onDelete) continue;
const [refTable] = colDef.references.split('.'); const [refTable] = colDef.references.split('.');
if (refTable !== tableName) continue; if (refTable !== tableName) continue;
// 自引用场景下 `getAllRows` 返回的是当前表的快照副本(cloneRow),
// 因此循环内的删除不会改变 `matched` —— 这正是这里能安全递归的原因。
const refRows = await this.getAllRows(refTableName); const refRows = await this.getAllRows(refTableName);
const matched = refRows.filter((r) => String(r[colName]) === pkValue); const matched = refRows.filter((r) => String(r[colName]) === pkValue);
+43 -12
View File
@@ -465,7 +465,13 @@ export class MemoryEngine implements IStorageEngine {
*/ */
private checkUpdateRestrict(tableName: string, oldPk: string): void { private checkUpdateRestrict(tableName: string, oldPk: string): void {
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) continue; // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
//
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) continue; if (!colDef.references || !colDef.onUpdate) continue;
const [refTable] = colDef.references.split('.'); const [refTable] = colDef.references.split('.');
@@ -504,7 +510,13 @@ export class MemoryEngine implements IStorageEngine {
*/ */
private async applyUpdateCascade(tableName: string, oldPk: string, newPk: string): Promise<void> { private async applyUpdateCascade(tableName: string, oldPk: string, newPk: string): Promise<void> {
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) continue; // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
//
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onUpdate) continue; if (!colDef.references || !colDef.onUpdate) continue;
const [refTable] = colDef.references.split('.'); const [refTable] = colDef.references.split('.');
@@ -582,7 +594,13 @@ export class MemoryEngine implements IStorageEngine {
visited.add(visitKey); visited.add(visitKey);
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) continue; // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
//
// 此前这里 `continue`,于是自引用外键(`parent_id REFERENCES node(id)`
// 在所有级联路径上都被整体跳过:删除只删根、设置不变、预检也不查。
// 自引用的处理与普通外键完全相同,唯一需要注意的是遍历时机:
// 删除路径必须先递归子树再删父行,且**先收集引用者再处理**
//(自引用时遍历的正是同一个 Map,边遍历边删会跳过条目)。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete) continue; if (!colDef.references || !colDef.onDelete) continue;
const [refTable] = colDef.references.split('.'); const [refTable] = colDef.references.split('.');
@@ -923,7 +941,14 @@ export class MemoryEngine implements IStorageEngine {
let totalCascade = 0; let totalCascade = 0;
for (const [refTableName, refSchema] of this.schemas) { for (const [refTableName, refSchema] of this.schemas) {
if (refTableName === tableName) continue; // v0.8.0A13):**不再跳过自引用外键**refTableName === tableName)。
//
// 此前这里 `continue`,于是 `parent_id REFERENCES node(id)` 这种树形自引用
// 完全不做级联。实测(本提交的用例锁定):
// INSERT node: root <- a <- b <- c
// DELETE root → 只删掉 roota/b/c 全部残留且 parent_id 指向已删除的行
// (且因为父行已删,它们之后**再也无法通过级联清理** —— 永久悬挂)
// 这是"删除留下悬挂引用"的静默数据不一致,比报错更糟。
for (const [colName, colDef] of Object.entries(refSchema.columns)) { for (const [colName, colDef] of Object.entries(refSchema.columns)) {
if (!colDef.references || !colDef.onDelete) continue; if (!colDef.references || !colDef.onDelete) continue;
@@ -934,16 +959,18 @@ export class MemoryEngine implements IStorageEngine {
const refTableData = this.tables.get(refTableName); const refTableData = this.tables.get(refTableName);
if (!refTableData) continue; if (!refTableData) continue;
// 查找所有引用此主键的行 // 查找所有引用此主键的行
const toDelete: string[] = []; //
// v0.8.0A13):**先完整收集再处理**。自引用场景下 `refTableData`
// 与当前遍历的表是同一个 Map,边遍历边删除会跳过条目(Map 迭代器
// 对已删除键的行为取决于删除位置)。收集成数组后处理即与迭代解耦。
const referrers: string[] = [];
for (const [refPk, refRow] of refTableData) { for (const [refPk, refRow] of refTableData) {
if (String(refRow[colName]) === pkValue) { if (String(refRow[colName]) === pkValue) referrers.push(refPk);
toDelete.push(refPk);
}
} }
// RESTRICT: 存在引用行时禁止删除 // RESTRICT: 存在引用行时禁止删除
if (colDef.onDelete === 'RESTRICT' && toDelete.length > 0) { if (colDef.onDelete === 'RESTRICT' && referrers.length > 0) {
throw new DatabaseError( throw new DatabaseError(
`Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`, `Cannot delete from "${tableName}": foreign key "${colName}" in "${refTableName}" has dependent rows`,
'FOREIGN_KEY_VIOLATION', 'FOREIGN_KEY_VIOLATION',
@@ -952,18 +979,22 @@ export class MemoryEngine implements IStorageEngine {
if (colDef.onDelete === 'CASCADE') { if (colDef.onDelete === 'CASCADE') {
// 递归级联 // 递归级联
for (const refPk of toDelete) { for (const refPk of referrers) {
const refRow = refTableData.get(refPk); const refRow = refTableData.get(refPk);
if (refRow) { if (refRow) {
// v0.3.3: 级联删除前清理索引条目 // v0.3.3: 级联删除前清理索引条目
this.removeIndexEntries(refTableName, refRow, refPk); this.removeIndexEntries(refTableName, refRow, refPk);
// v0.8.0(A13):自引用时**先递归再删自己** —— 子树必须先被清掉,
// 否则删掉父行后子行的 parent_id 就再也匹配不上(悬挂)。
totalCascade += await this.cascadeDelete(refTableName, refPk, refRow, visited); totalCascade += await this.cascadeDelete(refTableName, refPk, refRow, visited);
} }
if (refTableData.has(refPk)) {
refTableData.delete(refPk); refTableData.delete(refPk);
totalCascade++; totalCascade++;
} }
}
} else if (colDef.onDelete === 'SET NULL') { } else if (colDef.onDelete === 'SET NULL') {
for (const refPk of toDelete) { for (const refPk of referrers) {
const refRow = refTableData.get(refPk); const refRow = refTableData.get(refPk);
if (refRow) { if (refRow) {
// v0.6.3-fix: 复用 removeIndexEntries 清理旧值索引 —— 此前手动 // v0.6.3-fix: 复用 removeIndexEntries 清理旧值索引 —— 此前手动
+197
View File
@@ -0,0 +1,197 @@
/**
* v0.8.0 回归套件 —— 外键级联:自引用与传递链(A13/A14)
* ============================================================================
* 修复前的两个不同结论(都是实测得出,不是推断):
*
* A13 **自引用外键完全不做级联**(真实缺陷)
* MemoryEngine 与 AriaEngine 的级联实现里都有 `if (refTableName === tableName) continue;`
* —— 于是 `parent_id REFERENCES node(id)` 这种树形自引用被整体跳过:
* INSERT: root ← a ← b ← c
* DELETE root → 只删掉 roota/b/c 全部残留且 parent_id 指向已删除的行
* 因为父行已经不在了,这些行**之后再也无法通过级联清理**(永久悬挂)。
* `ON DELETE SET NULL` 同样完全失效(子行的 parent_id 保持旧值)。
*
* A14 ON UPDATE CASCADE 传递链(**审计结论有误,实测正常**)
* 审计记录为"A→B→C 链改 A 主键后 C 悬空",但本套件的用例证明:
* `UPDATE a SET id='A2'` 之后 b.a_id 正确变为 A2 —— C 引用的是 **b.id**(未变),
* 因此 C 并不需要更新,"悬空"的推断不成立。这里把它固化为回归护栏,
* 避免将来有人按那份错误结论去"修"一个不存在的问题。
*
* 核心不变量(本套件的重点):**四个引擎对同一份 schema + 同一组操作必须给出
* 相同结果**。此前 Memory 与 Aria 的跳过条件逐字相同,因此缺陷是同步的;
* 现在两边的修复也必须同步。
*/
import { MetonaSqlark } from '../src/core';
import { rows as rowsOf, expectCode } from './helpers/assertions';
import type { DatabaseConfig } from '../src/constants';
const ENGINES: Array<[string, DatabaseConfig['mode'], Partial<DatabaseConfig>]> = [
['memory', 'memory', {}],
['disk', 'disk', {}],
['hybrid', 'hybrid', {}],
['aria', 'aria', { diskEngine: 'memory' }],
];
describe('[v0.8.0] A13 自引用外键', () => {
describe.each(ENGINES)('%s 引擎', (label, mode, extra) => {
let db: MetonaSqlark;
let seq = 0;
beforeEach(async () => {
seq += 1;
db = await MetonaSqlark.create({
name: `a13-${label}-${seq}-${Math.random().toString(36).slice(2)}`,
mode,
...extra,
});
});
afterEach(async () => {
await db.close();
});
it('ON DELETE CASCADE:删根节点清掉整棵子树', async () => {
await db.query(
'CREATE TABLE node (id STRING PRIMARY KEY, parent_id STRING REFERENCES node(id) ON DELETE CASCADE)',
);
await db.query("INSERT INTO node VALUES ('root', NULL), ('a', 'root'), ('b', 'a'), ('c', 'b')");
// 修复前:只删掉 root,a/b/c 残留(且永久悬挂)
const affected = await db.query("DELETE FROM node WHERE id = 'root'");
expect(affected).toBe(4);
expect(rowsOf(await db.query('SELECT id FROM node'))).toEqual([]);
});
it('ON DELETE CASCADE:只删被删节点的子树,兄弟分支保留', async () => {
await db.query(
'CREATE TABLE node (id STRING PRIMARY KEY, parent_id STRING REFERENCES node(id) ON DELETE CASCADE)',
);
await db.query(
"INSERT INTO node VALUES ('root', NULL), ('a', 'root'), ('a1', 'a'), ('a2', 'a1'), ('x', 'root'), ('x1', 'x')",
);
// 删 a 及其子树 a1/a2root 与 x/x1 保留
const affected = await db.query("DELETE FROM node WHERE id = 'a'");
expect(affected).toBe(3);
const ids = rowsOf<{ id: string }>(await db.query('SELECT id FROM node ORDER BY id')).map((r) => r.id);
expect(ids).toEqual(['root', 'x', 'x1']);
});
it('ON DELETE SET NULL:直接子节点的外键置空(不递归)', async () => {
await db.query(
'CREATE TABLE node (id STRING PRIMARY KEY, parent_id STRING REFERENCES node(id) ON DELETE SET NULL)',
);
await db.query("INSERT INTO node VALUES ('root', NULL), ('a', 'root'), ('b', 'a')");
await db.query("DELETE FROM node WHERE id = 'root'");
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT id, parent_id FROM node ORDER BY id'),
);
// a 的直接父是 root → 置空;b 的父是 a(还在)→ 不变
expect(rows).toEqual([{ id: 'a', parent_id: null }, { id: 'b', parent_id: 'a' }]);
});
it('ON DELETE RESTRICT:有子节点时拒绝删除', async () => {
await db.query(
'CREATE TABLE node (id STRING PRIMARY KEY, parent_id STRING REFERENCES node(id) ON DELETE RESTRICT)',
);
await db.query("INSERT INTO node VALUES ('root', NULL), ('a', 'root')");
await expectCode(db.query("DELETE FROM node WHERE id = 'root'"), 'FOREIGN_KEY_VIOLATION');
// 拒绝后数据不得有任何变化
expect(rowsOf(await db.query('SELECT id FROM node'))).toHaveLength(2);
// 叶子节点可以删除
await db.query("DELETE FROM node WHERE id = 'a'");
expect(rowsOf<{ id: string }>(await db.query('SELECT id FROM node')).map((r) => r.id)).toEqual(['root']);
});
it('ON UPDATE CASCADE 对自引用同样生效', async () => {
await db.query(
'CREATE TABLE node (id STRING PRIMARY KEY, parent_id STRING REFERENCES node(id) ON UPDATE CASCADE)',
);
await db.query("INSERT INTO node VALUES ('root', NULL), ('a', 'root'), ('b', 'a')");
await db.query("UPDATE node SET id = 'root2' WHERE id = 'root'");
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT id, parent_id FROM node ORDER BY id'),
);
// a 的 parent_id 应随 root → root2 更新;b 不受影响
expect(rows).toEqual([
{ id: 'a', parent_id: 'root2' },
{ id: 'b', parent_id: 'a' },
{ id: 'root2', parent_id: null },
]);
});
});
});
describe('[v0.8.0] A14 外键传递链(跨表多层)', () => {
describe.each(ENGINES)('%s 引擎', (label, mode, extra) => {
let db: MetonaSqlark;
let seq = 0;
beforeEach(async () => {
seq += 1;
db = await MetonaSqlark.create({
name: `a14-${label}-${seq}-${Math.random().toString(36).slice(2)}`,
mode,
...extra,
});
});
afterEach(async () => {
await db.close();
});
it('ON DELETE CASCADE 沿 A→B→C 链传递', async () => {
await db.query('CREATE TABLE a (id STRING PRIMARY KEY)');
await db.query('CREATE TABLE b (id STRING PRIMARY KEY, a_id STRING REFERENCES a(id) ON DELETE CASCADE)');
await db.query('CREATE TABLE c (id STRING PRIMARY KEY, b_id STRING REFERENCES b(id) ON DELETE CASCADE)');
await db.query("INSERT INTO a VALUES ('A1')");
await db.query("INSERT INTO b VALUES ('B1', 'A1')");
await db.query("INSERT INTO c VALUES ('C1', 'B1')");
const affected = await db.query("DELETE FROM a WHERE id = 'A1'");
// a 1 行 + b 1 行 + c 1 行
expect(affected).toBe(3);
expect(rowsOf(await db.query('SELECT id FROM b'))).toEqual([]);
expect(rowsOf(await db.query('SELECT id FROM c'))).toEqual([]);
});
it('ON UPDATE CASCADE 沿 A→B 链传递,C 引用未变故不需更新', async () => {
// 审计曾记录"A→B→C 链改 A 主键后 C 悬空",实测不成立:
// C 引用的是 **b.id**(未被更新),因此 C 不需要任何变更。
// 本用例固化这一结论,避免将来按错误结论去"修"一个不存在的问题。
await db.query('CREATE TABLE a (id STRING PRIMARY KEY)');
await db.query('CREATE TABLE b (id STRING PRIMARY KEY, a_id STRING REFERENCES a(id) ON UPDATE CASCADE)');
await db.query('CREATE TABLE c (id STRING PRIMARY KEY, b_id STRING REFERENCES b(id) ON UPDATE CASCADE)');
await db.query("INSERT INTO a VALUES ('A1')");
await db.query("INSERT INTO b VALUES ('B1', 'A1')");
await db.query("INSERT INTO c VALUES ('C1', 'B1')");
await db.query("UPDATE a SET id = 'A2' WHERE id = 'A1'");
expect(rowsOf<Record<string, unknown>>(await db.query('SELECT id, a_id FROM b'))).toEqual([
{ id: 'B1', a_id: 'A2' },
]);
// C 仍指向 B1(未变)—— 没有悬空:B1 依然存在
expect(rowsOf<Record<string, unknown>>(await db.query('SELECT id, b_id FROM c'))).toEqual([
{ id: 'C1', b_id: 'B1' },
]);
// 真正会悬空的是"把 b.id 也改掉"的场景:此时 C 必须跟着更新
await db.query("UPDATE b SET id = 'B2' WHERE id = 'B1'");
expect(rowsOf<Record<string, unknown>>(await db.query('SELECT id, b_id FROM c'))).toEqual([
{ id: 'C1', b_id: 'B2' },
]);
});
it('链上 RESTRICT 阻断整条级联(整体拒绝,无部分删除)', async () => {
await db.query('CREATE TABLE a (id STRING PRIMARY KEY)');
await db.query('CREATE TABLE b (id STRING PRIMARY KEY, a_id STRING REFERENCES a(id) ON DELETE CASCADE)');
await db.query('CREATE TABLE c (id STRING PRIMARY KEY, b_id STRING REFERENCES b(id) ON DELETE RESTRICT)');
await db.query("INSERT INTO a VALUES ('A1')");
await db.query("INSERT INTO b VALUES ('B1', 'A1')");
await db.query("INSERT INTO c VALUES ('C1', 'B1')");
await expectCode(db.query("DELETE FROM a WHERE id = 'A1'"), 'FOREIGN_KEY_VIOLATION');
// 两阶段预检:拒绝后三张表都必须完好(不能出现"b 被删了、a 还在"
expect(rowsOf(await db.query('SELECT id FROM a'))).toHaveLength(1);
expect(rowsOf(await db.query('SELECT id FROM b'))).toHaveLength(1);
expect(rowsOf(await db.query('SELECT id FROM c'))).toHaveLength(1);
});
});
});