Files
MetonaSqlark/tests/v080-foreign-key.test.ts
T
thzxx 85f0f170a4 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 已重建。
2026-09-15 01:36:29 +08:00

198 lines
9.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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);
});
});
});