Files
MetonaSqlark/tests/v080-correlated.test.ts
T
thzxx 674da6b7b7 fix(A9/A10): 发布订阅接线 + 列对列比较与关联子查询(静默空结果根治)
A9 db.subscribe 对本地写入永不触发
  全库唯一调用 emit 的地方在 BroadcastChannel 收到**其它标签页**消息的分支里,
  于是 README:232「订阅表变更」与 site/docs.html:667-679 的示例
  (event.type: 'insert'|'update'|'delete'、event.row)全部不成立。

  根治方式:新增 src/engine/change-notifier.ts —— IStorageEngine 装饰器,
  把变更通知收敛到**引擎接口**这一个位置(三个写入入口 SQL/Table/Builder 与
  事务内写入都必须经过它),避免在三条路径上各写一份变更描述逻辑。

  事件语义(兑现文档承诺):INSERT 逐行带 row+key;UPDATE/DELETE 写入前快照
  受影响行、成功后逐行发事件并带更新后/删除前的行;CLEAR/DDL 表级事件。
  订阅者返回 Promise 时被 await;订阅者抛错不影响写入结果(只上报 onError)。

  两个实现细节值得记录:
  1. 引擎被装饰后,core 里 `this.engine instanceof HybridEngine` 恒为 false
     → Hybrid 跨标签页重载静默失效。新增 unwrapEngine() 对**内层**引擎做能力探测。
  2. 外部事件(external)绝不能重新广播 —— 否则 A↔B 互相转发形成无限循环
     (实测 8 次以上且不终止)。已分离 emitExternal 路径。

A10 列对列比较与关联 IN 子查询静默空结果
  1) `WHERE t.x = t.y`(唯一可解析的列对列写法)返回 []:
     - 引擎层 matchWhere 无 $col 上下文,把 `{ $col: ... }` 当普通对象比较;
     - executor.filterCorrelated 调用 matchWhere 时**没传** `{ $col: true }`。
     修复:engine 层遇到未解析操作数($col/$subquery)时**放行**而非判假 ——
     引擎的过滤只允许缩小候选集,最终判定始终由带上下文的 executor 完成;
     executor 侧补上 `{ $col: true }`。
     同时修正 MemoryEngine/AriaEngine 的索引下推:非原始值(对象)不走索引,
     否则 String({...}) 得到无意义键、查找为空并短路全表扫描 → 静默空结果。

  2) `WHERE id IN (SELECT user_id FROM o WHERE o.user_id = u.id)` 返回 []:
     子查询执行**不传外层行上下文**,`u.id` 绑定为 null → 子查询空集 → `$in: []`。
     (结构相同的 EXISTS 走另一条分支、结果正确 —— 又一处"同一语义两条路径"。)
     修复:resolveOperatorSubqueries 接收并传递 contextRow;bindColumnRefs 递归
     进入 $subquery 绑定外层引用;新增 lookupOuterValue 先剥外层表名/别名前缀
     再取值(外层行键不带前缀,否则 `u.id` 取 undefined 被 `?? null` 静默成 null)。

ChangeNotifierEngine 能力转发
  装饰器只实现 IStorageEngine 声明的成员,导致:
  - 可选能力缺失时抛原生 Error,破坏 `NOT_SUPPORTED` 错误码契约(14 个用例失败)
    → 新增 requireCapability,统一抛 NOT_SUPPORTED 并保留方法名;
  - 接口外方法(analyzeTable/reindexTable/vacuum)在被包装后静默消失
    → 新增 requireOptionalMethod 显式转发(ANALYZE/REINDEX/VACUUM 恢复可用)。

新增 tests/v080-subscribe.test.ts(5 用例,四引擎 × 三种入口)、
tests/v080-correlated.test.ts(4 用例,含"关联 IN 与等价 EXISTS 结果一致"护栏)。
2026-09-14 22:29:51 +08:00

90 lines
4.2 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 回归 —— A10 列对列比较与关联子查询
*
* 两个此前**静默返回空结果**的缺陷(行数正确、内容全空、无任何报错):
*
* 1. `WHERE t.x = t.y`(唯一可解析的列对列写法,解析为 `{ 't.x': { $eq: { $col: 't.y' } } }`
* 执行路径是:引擎层先按原始 where 取候选行(引擎的 matchWhere **没有** $col 上下文)
* → executor.filterCorrelated 带 `{ $col: true }` 做最终判定。
* 结果两处都错了:
* - 引擎层把 `{ $col: ... }` 当普通对象比较 → 所有行都不匹配;
* - executor 侧调用 matchWhere 时又**没传** `{ $col: true }`。
* 实测返回 `[]`(应返回 x == y 的行)。
*
* 2. `WHERE id IN (SELECT ... WHERE o.user_id = u.id)`(关联 IN 子查询)
* 子查询执行时**不传外层行上下文**,`u.id` 绑定为 null,子查询返回空集 →
* `$in: []` → 静默空结果。而结构相同的 EXISTS 走另一条分支、结果是正确的
* —— 又一处"同一语义两条路径"。
*
* 修复后两者与等价写法结果一致(见下方断言)。
*/
import { MetonaSqlark } from '../src/core';
const MODES: Array<[string, Record<string, unknown>]> = [
['memory', {}],
['disk', {}],
['hybrid', {}],
['aria', { diskEngine: 'memory' }],
];
describe('[v0.8.0] A10 列对列比较', () => {
test('WHERE t.x = t.y 返回相等行(四引擎)', async () => {
for (const [mode, extra] of MODES) {
const db = await MetonaSqlark.create({ name: `v080-a10-${mode}`, mode, ...extra } as never);
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
x: { type: 'number' },
y: { type: 'number' },
});
await db.query("INSERT INTO t VALUES ('1',1,5),('2',2,3),('3',7,7),('4',7,7)");
const rows = await db.query('SELECT id FROM t WHERE t.x = t.y') as Array<{ id: string }>;
expect(rows.map((r) => r.id).sort()).toEqual(['3', '4']);
await db.close();
}
});
test('裸 x = y 仍为显式 PARSE_ERROR(语法限制,不静默)', async () => {
const db = await MetonaSqlark.create({ name: 'v080-a10-bare', mode: 'memory' });
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, x: { type: 'number' }, y: { type: 'number' } });
await expect(db.query('SELECT id FROM t WHERE x = y')).rejects.toMatchObject({ code: 'PARSE_ERROR' });
await db.close();
});
});
describe('[v0.8.0] A10 关联 IN 子查询', () => {
test('关联 IN 与等价 EXISTS 结果一致(四引擎)', async () => {
for (const [mode, extra] of MODES) {
const db = await MetonaSqlark.create({ name: `v080-a10b-${mode}`, mode, ...extra } as never);
await db.defineTable('u', { id: { type: 'string', primaryKey: true } });
await db.defineTable('o', { id: { type: 'string', primaryKey: true }, user_id: { type: 'string' } });
await db.query("INSERT INTO u VALUES ('1'),('2'),('3')");
await db.query("INSERT INTO o VALUES ('o1','1'),('o2','2')");
const viaIn = await db.query(
'SELECT id FROM u WHERE id IN (SELECT user_id FROM o WHERE o.user_id = u.id)',
) as Array<{ id: string }>;
const viaExists = await db.query(
'SELECT id FROM u WHERE EXISTS (SELECT 1 FROM o WHERE o.user_id = u.id)',
) as Array<{ id: string }>;
expect(viaIn.map((r) => r.id).sort()).toEqual(['1', '2']);
// 关键护栏:两条语义等价的路径必须给出一致结果
expect(viaIn.map((r) => r.id).sort()).toEqual(viaExists.map((r) => r.id).sort());
await db.close();
}
});
test('非关联 IN 子查询不受影响(回归护栏)', async () => {
const db = await MetonaSqlark.create({ name: 'v080-a10c', mode: 'memory' });
await db.defineTable('u', { id: { type: 'string', primaryKey: true } });
await db.defineTable('o', { id: { type: 'string', primaryKey: true }, user_id: { type: 'string' } });
await db.query("INSERT INTO u VALUES ('1'),('2'),('3')");
await db.query("INSERT INTO o VALUES ('o1','1'),('o2','2')");
const rows = await db.query('SELECT id FROM u WHERE id IN (SELECT user_id FROM o)') as Array<{ id: string }>;
expect(rows.map((r) => r.id).sort()).toEqual(['1', '2']);
await db.close();
});
});