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 结果一致"护栏)。
This commit is contained in:
@@ -49,19 +49,21 @@ describe('Core 全覆盖', () => {
|
||||
expect(typeof unsub).toBe('function');
|
||||
});
|
||||
|
||||
it('unsubscribe 取消监听', () => {
|
||||
it('unsubscribe 取消监听', async () => {
|
||||
const fn = jest.fn();
|
||||
const unsub = db.subscribe('users', fn);
|
||||
unsub();
|
||||
db.emit('users', { type: 'insert', row: { id: '3' } });
|
||||
await db.emit('users', { type: 'insert', row: { id: '3' } });
|
||||
expect(fn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('emit 触发监听', () => {
|
||||
it('emit 触发监听', async () => {
|
||||
const fn = jest.fn();
|
||||
db.subscribe('users', fn);
|
||||
db.emit('users', { type: 'insert', row: { id: '3', name: 'Charlie' } });
|
||||
expect(fn).toHaveBeenCalledWith({ type: 'insert', row: { id: '3', name: 'Charlie' } });
|
||||
// v0.8.0: emit 现在会补全 table 字段(事件语义统一),且返回 Promise
|
||||
await db.emit('users', { type: 'insert', row: { id: '3', name: 'Charlie' } });
|
||||
// 事件现在带 table 字段(与引擎层变更事件语义一致)
|
||||
expect(fn).toHaveBeenCalledWith({ table: 'users', type: 'insert', row: { id: '3', name: 'Charlie' } });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -259,7 +259,7 @@ describe('[v0.3.2] 多标签页同步', () => {
|
||||
await dbB.init();
|
||||
|
||||
const events: { type: string; table?: string }[] = [];
|
||||
dbB.subscribe('t', (e) => events.push(e));
|
||||
dbB.subscribe('t', (e) => { events.push(e); });
|
||||
|
||||
await dbA.query(`INSERT INTO t VALUES ('1', 10)`);
|
||||
|
||||
@@ -315,7 +315,7 @@ describe('[v0.3.2] 多标签页同步', () => {
|
||||
await dbB.init();
|
||||
|
||||
const events: unknown[] = [];
|
||||
dbB.subscribe('t', (e) => events.push(e));
|
||||
dbB.subscribe('t', (e) => { events.push(e); });
|
||||
|
||||
await dbA.query(`INSERT INTO t VALUES ('1', 10)`);
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
@@ -337,7 +337,7 @@ describe('[v0.3.2] 多标签页同步', () => {
|
||||
await dbB.init();
|
||||
|
||||
const events: unknown[] = [];
|
||||
dbB.subscribe('t', (e) => events.push(e));
|
||||
dbB.subscribe('t', (e) => { events.push(e); });
|
||||
|
||||
await dbA.table('t').insert({ id: '1', v: 10 });
|
||||
expect(events.length).toBeGreaterThan(0);
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* v0.8.0 回归 —— A9 发布订阅(本地写入必须触发订阅者)
|
||||
*
|
||||
* 修复前:`db.subscribe(table, fn)` 对**本地写入永不触发** —— 全库唯一调用 `emit`
|
||||
* 的地方在 BroadcastChannel 收到其它标签页消息的分支里。于是 README:232
|
||||
* 「订阅表变更」与 site/docs.html:667-679 的示例(`event.type: 'insert' | 'update' |
|
||||
* 'delete'`、`event.row`)全部不成立;唯一实际触发条件是 `multiTabSync: true`
|
||||
* 且收到 `external` 事件。
|
||||
*
|
||||
* 修复方式:用 `ChangeNotifierEngine` 装饰器把变更通知收敛到**引擎接口**这一个位置。
|
||||
* 三个写入入口(SQL 语句 / Table API / QueryBuilder)与事务内写入都必须经过引擎,
|
||||
* 因此拦一次即可全覆盖,避免在三条路径上各写一份变更描述逻辑(那正是本项目
|
||||
* 反复出现"同一语义多份实现 → 漂移"的模式)。
|
||||
*
|
||||
* 覆盖:四个引擎 × 三种写入入口 × 事件内容(type/table/row/key/count)+
|
||||
* 退订 + async 订阅者 + 跨标签页广播不形成回路。
|
||||
*/
|
||||
import { MetonaSqlark } from '../src/core';
|
||||
import type { ChangeEvent } from '../src/engine/change-notifier';
|
||||
|
||||
const MODES: Array<[string, Record<string, unknown>]> = [
|
||||
['memory', {}],
|
||||
['disk', {}],
|
||||
['hybrid', {}],
|
||||
['aria', { diskEngine: 'memory' }],
|
||||
];
|
||||
|
||||
describe('[v0.8.0] A9 发布订阅 —— SQL 写入路径', () => {
|
||||
test('INSERT/UPDATE/DELETE 逐行事件,携带行内容与主键', async () => {
|
||||
for (const [mode, extra] of MODES) {
|
||||
const db = await MetonaSqlark.create({ name: `v080-a9-${mode}`, mode, ...extra } as never);
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
|
||||
const events: ChangeEvent[] = [];
|
||||
const unsub = db.subscribe('t', (e) => { events.push(e); });
|
||||
|
||||
await db.query("INSERT INTO t VALUES ('a',1),('b',2)");
|
||||
await db.query("UPDATE t SET v = 9 WHERE id = 'a'");
|
||||
await db.query("DELETE FROM t WHERE id = 'b'");
|
||||
|
||||
expect(events.map((e) => e.type)).toEqual(['insert', 'insert', 'update', 'delete']);
|
||||
// 逐行事件带主键与行内容
|
||||
expect(events.filter((e) => e.type === 'insert').map((e) => e.key).sort()).toEqual(['a', 'b']);
|
||||
const update = events.find((e) => e.type === 'update')!;
|
||||
expect(update.row).toMatchObject({ id: 'a', v: 9 });
|
||||
expect(update.table).toBe('t');
|
||||
const del = events.find((e) => e.type === 'delete')!;
|
||||
expect(del.row).toMatchObject({ id: 'b', v: 2 });
|
||||
|
||||
// 退订后不再收到
|
||||
unsub();
|
||||
await db.query("INSERT INTO t VALUES ('c',3)");
|
||||
expect(events).toHaveLength(4);
|
||||
|
||||
await db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('[v0.8.0] A9 发布订阅 —— Table API / QueryBuilder 路径', () => {
|
||||
test('insert / insertMany / update / delete / clear 全部产生事件', async () => {
|
||||
for (const [mode, extra] of MODES) {
|
||||
const db = await MetonaSqlark.create({ name: `v080-a9b-${mode}`, mode, ...extra } as never);
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
|
||||
const events: ChangeEvent[] = [];
|
||||
db.subscribe('t', (e) => { events.push(e); });
|
||||
|
||||
await db.table('t').insert({ id: 'a', v: 1 });
|
||||
await db.table('t').insertMany([{ id: 'b', v: 2 }, { id: 'c', v: 3 }]);
|
||||
await db.table('t').update({ v: 9 }).where({ id: 'a' }).execute();
|
||||
await db.table('t').delete().where({ id: 'b' }).execute();
|
||||
await db.table('t').clear();
|
||||
|
||||
expect(events.map((e) => e.type))
|
||||
.toEqual(['insert', 'insert', 'insert', 'update', 'delete', 'clear']);
|
||||
await db.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('[v0.8.0] A9 发布订阅 —— 语义护栏', () => {
|
||||
test('异步订阅者会被 await(写完即通知完)', async () => {
|
||||
const db = await MetonaSqlark.create({ name: 'v080-a9-async', mode: 'memory' });
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||
const seen: string[] = [];
|
||||
db.subscribe('t', async (e) => {
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
seen.push(`${e.type}:${e.key ?? ''}`);
|
||||
});
|
||||
await db.query("INSERT INTO t VALUES ('x')");
|
||||
expect(seen).toEqual(['insert:x']);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('订阅者抛错不影响写入结果(写入已成功)', async () => {
|
||||
const errors: Error[] = [];
|
||||
const db = await MetonaSqlark.create({
|
||||
name: 'v080-a9-throw',
|
||||
mode: 'memory',
|
||||
onError: (e: Error) => { errors.push(e); },
|
||||
} as never);
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||
db.subscribe('t', () => { throw new Error('subscriber boom'); });
|
||||
|
||||
await expect(db.query("INSERT INTO t VALUES ('x')")).resolves.toEqual(['x']);
|
||||
const rows = await db.query('SELECT COUNT(*) AS c FROM t') as Array<{ c: number }>;
|
||||
expect(rows[0].c).toBe(1);
|
||||
expect(errors.some((e) => e.message === 'subscriber boom')).toBe(true);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('手动 emit 仍可用,并补全 table 字段', async () => {
|
||||
const db = await MetonaSqlark.create({ name: 'v080-a9-emit', mode: 'memory' });
|
||||
const events: ChangeEvent[] = [];
|
||||
db.subscribe('t', (e) => { events.push(e); });
|
||||
await db.emit('t', { type: 'insert', row: { id: 'z' } });
|
||||
expect(events).toEqual([{ table: 't', type: 'insert', row: { id: 'z' } }]);
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user