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 结果一致"护栏)。
406 lines
15 KiB
TypeScript
406 lines
15 KiB
TypeScript
/**
|
|
* v0.3.2 功能测试
|
|
* @module tests/sql-ext3
|
|
*
|
|
* 覆盖:CASE WHEN 用于 WHERE/聚合 / JOIN 哈希连接 / 多标签页同步
|
|
*/
|
|
import { MetonaSqlark } from '../src/core';
|
|
|
|
async function createDb(mode: 'memory' | 'hybrid' = 'memory', extra: Record<string, unknown> = {}) {
|
|
const db = new MetonaSqlark({
|
|
name: `sql-ext3-${mode}-${Date.now()}-${Math.random()}`,
|
|
mode,
|
|
diskEngine: 'opfs',
|
|
...extra,
|
|
});
|
|
await db.init();
|
|
await db.defineTable('users', {
|
|
id: { type: 'string', primaryKey: true },
|
|
name: { type: 'string' },
|
|
age: { type: 'number' },
|
|
city: { type: 'string' },
|
|
});
|
|
await db.defineTable('orders', {
|
|
id: { type: 'string', primaryKey: true },
|
|
user_id: { type: 'string' },
|
|
amount: { type: 'number' },
|
|
});
|
|
await db.query(`INSERT INTO users VALUES ('u1', 'Alice', 30, 'Beijing')`);
|
|
await db.query(`INSERT INTO users VALUES ('u2', 'Bob', 17, 'Shanghai')`);
|
|
await db.query(`INSERT INTO users VALUES ('u3', 'Carol', 42, 'Beijing')`);
|
|
await db.query(`INSERT INTO orders VALUES ('o1', 'u1', 100)`);
|
|
await db.query(`INSERT INTO orders VALUES ('o2', 'u1', 200)`);
|
|
await db.query(`INSERT INTO orders VALUES ('o3', 'u2', 50)`);
|
|
return db;
|
|
}
|
|
|
|
// ===================================================================
|
|
// CASE WHEN 用于 WHERE
|
|
// ===================================================================
|
|
|
|
describe('[v0.3.2] CASE WHEN 用于 WHERE', () => {
|
|
test('WHERE CASE 等值比较', async () => {
|
|
const db = await createDb();
|
|
const rows = await db.query(
|
|
`SELECT name FROM users WHERE CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END = 'adult'`,
|
|
) as Record<string, unknown>[];
|
|
expect(rows.map((r) => r.name).sort()).toEqual(['Alice', 'Carol']);
|
|
await db.close();
|
|
});
|
|
|
|
test('WHERE CASE 与 AND 组合', async () => {
|
|
const db = await createDb();
|
|
const rows = await db.query(
|
|
`SELECT name FROM users WHERE CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END = 'adult' AND city = 'Beijing'`,
|
|
) as Record<string, unknown>[];
|
|
expect(rows.map((r) => r.name).sort()).toEqual(['Alice', 'Carol']);
|
|
await db.close();
|
|
});
|
|
|
|
test('WHERE CASE 数字比较', async () => {
|
|
const db = await createDb();
|
|
const rows = await db.query(
|
|
`SELECT name FROM users WHERE CASE WHEN city = 'Beijing' THEN 1 ELSE 0 END = 1`,
|
|
) as Record<string, unknown>[];
|
|
expect(rows.map((r) => r.name).sort()).toEqual(['Alice', 'Carol']);
|
|
await db.close();
|
|
});
|
|
|
|
test('WHERE NOT CASE 组合', async () => {
|
|
const db = await createDb();
|
|
const rows = await db.query(
|
|
`SELECT name FROM users WHERE NOT (CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END = 'adult')`,
|
|
) as Record<string, unknown>[];
|
|
expect(rows.map((r) => r.name)).toEqual(['Bob']);
|
|
await db.close();
|
|
});
|
|
|
|
test('Aria 引擎 WHERE CASE 可用', async () => {
|
|
const db = await createDb('hybrid');
|
|
const rows = await db.query(
|
|
`SELECT name FROM users WHERE CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END = 'minor'`,
|
|
) as Record<string, unknown>[];
|
|
expect(rows.map((r) => r.name)).toEqual(['Bob']);
|
|
await db.close();
|
|
});
|
|
});
|
|
|
|
// ===================================================================
|
|
// CASE WHEN 用于聚合
|
|
// ===================================================================
|
|
|
|
describe('[v0.3.2] CASE WHEN 用于聚合', () => {
|
|
test('SUM(CASE WHEN...) 条件计数', async () => {
|
|
const db = await createDb();
|
|
const rows = await db.query(
|
|
`SELECT SUM(CASE WHEN age >= 18 THEN 1 ELSE 0 END) AS adults FROM users`,
|
|
) as Record<string, unknown>[];
|
|
expect(rows[0].adults).toBe(2);
|
|
await db.close();
|
|
});
|
|
|
|
test('COUNT(CASE WHEN...) 与 AVG', async () => {
|
|
const db = await createDb();
|
|
const rows = await db.query(
|
|
`SELECT COUNT(CASE WHEN city = 'Beijing' THEN 1 END) AS bj_count, AVG(CASE WHEN age >= 18 THEN age END) AS adult_avg FROM users`,
|
|
) as Record<string, unknown>[];
|
|
expect(rows[0].bj_count).toBe(2);
|
|
expect(rows[0].adult_avg).toBe(36); // (30 + 42) / 2
|
|
await db.close();
|
|
});
|
|
|
|
test('GROUP BY + SUM(CASE WHEN...)', async () => {
|
|
const db = await createDb();
|
|
const rows = await db.query(
|
|
`SELECT city, SUM(CASE WHEN age >= 18 THEN 1 ELSE 0 END) AS adults FROM users GROUP BY city`,
|
|
) as Record<string, unknown>[];
|
|
const byCity = Object.fromEntries(rows.map((r) => [r.city, r.adults]));
|
|
expect(byCity['Beijing']).toBe(2); // Alice + Carol
|
|
expect(byCity['Shanghai']).toBe(0); // Bob 17 岁
|
|
await db.close();
|
|
});
|
|
|
|
test('GROUP BY + CASE 非聚合列', async () => {
|
|
const db = await createDb();
|
|
const rows = await db.query(
|
|
`SELECT city, CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END AS status FROM users GROUP BY city`,
|
|
) as Record<string, unknown>[];
|
|
const byCity = Object.fromEntries(rows.map((r) => [r.city, r.status]));
|
|
expect(byCity['Beijing']).toBe('adult'); // 组内第一行 Alice
|
|
expect(byCity['Shanghai']).toBe('minor');
|
|
await db.close();
|
|
});
|
|
});
|
|
|
|
// ===================================================================
|
|
// JOIN 哈希连接
|
|
// ===================================================================
|
|
|
|
describe('[v0.3.2] JOIN 哈希连接', () => {
|
|
test('INNER JOIN 主键等值走哈希连接(结果正确)', async () => {
|
|
const db = await createDb();
|
|
const rows = await db.query(
|
|
`SELECT o.id FROM orders o INNER JOIN users u ON u.id = o.user_id`,
|
|
) as Record<string, unknown>[];
|
|
// orders 全有匹配用户
|
|
expect(rows.map((r) => r['o.id']).sort()).toEqual(['o1', 'o2', 'o3']);
|
|
await db.close();
|
|
});
|
|
|
|
test('LEFT JOIN 哈希连接保留未匹配行(null 填充)', async () => {
|
|
const db = await createDb();
|
|
await db.query(`INSERT INTO users VALUES ('u9', 'Zoe', 20, 'Guangzhou')`);
|
|
const rows = await db.query(
|
|
`SELECT u.name, o.id FROM users u LEFT JOIN orders o ON o.user_id = u.id`,
|
|
) as Record<string, unknown>[];
|
|
// Zoe 无订单 → 保留(null 填充);Alice 有 2 个订单 → 展开 2 行(LEFT JOIN 语义)
|
|
expect(rows.map((r) => r['u.name']).sort()).toEqual(['Alice', 'Alice', 'Bob', 'Carol', 'Zoe']);
|
|
const zoe = rows.find((r) => r['u.name'] === 'Zoe');
|
|
expect(zoe).toBeDefined();
|
|
expect(zoe!['o.id']).toBeNull();
|
|
await db.close();
|
|
});
|
|
|
|
test('INNER JOIN 哈希连接过滤无匹配行', async () => {
|
|
const db = await createDb();
|
|
await db.query(`INSERT INTO users VALUES ('u9', 'Zoe', 20, 'Guangzhou')`);
|
|
const rows = await db.query(
|
|
`SELECT u.name FROM users u INNER JOIN orders o ON o.user_id = u.id`,
|
|
) as Record<string, unknown>[];
|
|
// Alice 2 个订单 → 2 行;Bob 1 行;Carol 无订单被过滤
|
|
expect(rows.map((r) => r['u.name']).sort()).toEqual(['Alice', 'Alice', 'Bob']);
|
|
await db.close();
|
|
});
|
|
|
|
test('哈希连接一次 $in 查询(不再全表拉取)', async () => {
|
|
const db = await createDb();
|
|
const engine = db.getEngine() as any;
|
|
let rightTableFinds = 0;
|
|
const origFind = engine.find.bind(engine);
|
|
engine.find = async (table: string, query: any) => {
|
|
if (table === 'orders') {
|
|
rightTableFinds++;
|
|
if (query.where?.user_id?.$in) {
|
|
// 哈希连接:$in 一次查询
|
|
expect(query.where.user_id.$in).toEqual(expect.arrayContaining(['u1', 'u2']));
|
|
}
|
|
}
|
|
return origFind(table, query);
|
|
};
|
|
await db.query(`SELECT u.name FROM users u INNER JOIN orders o ON o.user_id = u.id`);
|
|
// orders.user_id 无索引 → 哈希回退;仍应恰有一次右表查询
|
|
expect(rightTableFinds).toBe(1);
|
|
await db.close();
|
|
});
|
|
|
|
test('哈希连接不适用于非索引右列(回退嵌套循环)', async () => {
|
|
const db = await createDb();
|
|
// orders.user_id 无索引 → 回退;结果仍正确
|
|
const rows = await db.query(
|
|
`SELECT o.id FROM orders o INNER JOIN users u ON u.id = o.user_id`,
|
|
) as Record<string, unknown>[];
|
|
expect(rows).toHaveLength(3);
|
|
await db.close();
|
|
});
|
|
});
|
|
|
|
// ===================================================================
|
|
// 多标签页同步
|
|
// ===================================================================
|
|
|
|
describe('[v0.3.2] 多标签页同步', () => {
|
|
// BroadcastChannel mock:模拟同源标签页间消息传递
|
|
class MockBroadcastChannel {
|
|
static instances: MockBroadcastChannel[] = [];
|
|
name: string;
|
|
onmessage: ((event: { data: unknown }) => void) | null = null;
|
|
closed = false;
|
|
constructor(name: string) {
|
|
this.name = name;
|
|
MockBroadcastChannel.instances.push(this);
|
|
}
|
|
postMessage(data: unknown): void {
|
|
if (this.closed) return;
|
|
for (const other of MockBroadcastChannel.instances) {
|
|
if (other !== this && other.name === this.name && !other.closed && other.onmessage) {
|
|
other.onmessage({ data });
|
|
}
|
|
}
|
|
}
|
|
close(): void {
|
|
this.closed = true;
|
|
}
|
|
static reset(): void {
|
|
MockBroadcastChannel.instances = [];
|
|
}
|
|
}
|
|
|
|
const origBC = (globalThis as any).BroadcastChannel;
|
|
beforeAll(() => {
|
|
(globalThis as any).BroadcastChannel = MockBroadcastChannel;
|
|
});
|
|
afterAll(() => {
|
|
(globalThis as any).BroadcastChannel = origBC;
|
|
});
|
|
beforeEach(() => {
|
|
MockBroadcastChannel.reset();
|
|
});
|
|
|
|
test('SQL 写语句广播表变更,其他标签页订阅收到 external 事件', async () => {
|
|
// 先建表(DDL 版本升级会触发其他标签页 onversionchange 关闭连接,故先建表再开第二连接)
|
|
const setup = new MetonaSqlark({ name: 'mt-a', mode: 'hybrid', diskEngine: 'opfs' });
|
|
await setup.init();
|
|
await setup.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
|
|
await setup.close();
|
|
|
|
const dbA = new MetonaSqlark({ name: 'mt-a', version: 2, mode: 'hybrid', diskEngine: 'opfs', multiTabSync: true });
|
|
const dbB = new MetonaSqlark({ name: 'mt-a', version: 2, mode: 'hybrid', diskEngine: 'opfs', multiTabSync: true });
|
|
await dbA.init();
|
|
await dbB.init();
|
|
|
|
const events: { type: string; table?: string }[] = [];
|
|
dbB.subscribe('t', (e) => { events.push(e); });
|
|
|
|
await dbA.query(`INSERT INTO t VALUES ('1', 10)`);
|
|
|
|
// 等待广播送达(同步 mock 已即时)
|
|
expect(events.length).toBeGreaterThan(0);
|
|
expect(events[0].type).toBe('external');
|
|
expect(events[0].table).toBe('t');
|
|
|
|
await dbA.close();
|
|
await dbB.close();
|
|
});
|
|
|
|
test('Hybrid 标签页收到广播后内存重载(读到其他标签页的新数据)', async () => {
|
|
const setup = new MetonaSqlark({ name: 'mt-b', mode: 'hybrid', diskEngine: 'opfs' });
|
|
await setup.init();
|
|
await setup.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
|
|
await setup.close();
|
|
|
|
const dbA = new MetonaSqlark({ name: 'mt-b', version: 2, mode: 'hybrid', diskEngine: 'opfs', multiTabSync: true });
|
|
const dbB = new MetonaSqlark({ name: 'mt-b', version: 2, mode: 'hybrid', diskEngine: 'opfs', multiTabSync: true });
|
|
await dbA.init();
|
|
await dbB.init();
|
|
|
|
// B 订阅外部变更后等待 reload 完成
|
|
let reloadDone: Promise<void> = Promise.resolve();
|
|
dbB.subscribe('t', async () => {
|
|
reloadDone = reloadDone.then(async () => {
|
|
// Hybrid reload 由 onmessage 触发(异步),订阅回调后再等一拍
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
});
|
|
});
|
|
|
|
await dbA.query(`INSERT INTO t VALUES ('1', 100)`);
|
|
await new Promise((r) => setTimeout(r, 80));
|
|
|
|
const rows = await dbB.query('SELECT * FROM t') as Record<string, unknown>[];
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].v).toBe(100);
|
|
|
|
await dbA.close();
|
|
await dbB.close();
|
|
});
|
|
|
|
test('未启用 multiTabSync 不广播', async () => {
|
|
const setup = new MetonaSqlark({ name: 'mt-c', mode: 'hybrid', diskEngine: 'opfs' });
|
|
await setup.init();
|
|
await setup.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
|
|
await setup.close();
|
|
|
|
const dbA = new MetonaSqlark({ name: 'mt-c', version: 2, mode: 'hybrid', diskEngine: 'opfs' });
|
|
const dbB = new MetonaSqlark({ name: 'mt-c', version: 2, mode: 'hybrid', diskEngine: 'opfs', multiTabSync: true });
|
|
await dbA.init();
|
|
await dbB.init();
|
|
|
|
const events: unknown[] = [];
|
|
dbB.subscribe('t', (e) => { events.push(e); });
|
|
|
|
await dbA.query(`INSERT INTO t VALUES ('1', 10)`);
|
|
await new Promise((r) => setTimeout(r, 30));
|
|
expect(events).toHaveLength(0); // dbA 未启用 → 无广播
|
|
|
|
await dbA.close();
|
|
await dbB.close();
|
|
});
|
|
|
|
test('Table API 写入也广播', async () => {
|
|
const setup = new MetonaSqlark({ name: 'mt-d', mode: 'hybrid', diskEngine: 'opfs' });
|
|
await setup.init();
|
|
await setup.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
|
|
await setup.close();
|
|
|
|
const dbA = new MetonaSqlark({ name: 'mt-d', version: 2, mode: 'hybrid', diskEngine: 'opfs', multiTabSync: true });
|
|
const dbB = new MetonaSqlark({ name: 'mt-d', version: 2, mode: 'hybrid', diskEngine: 'opfs', multiTabSync: true });
|
|
await dbA.init();
|
|
await dbB.init();
|
|
|
|
const events: unknown[] = [];
|
|
dbB.subscribe('t', (e) => { events.push(e); });
|
|
|
|
await dbA.table('t').insert({ id: '1', v: 10 });
|
|
expect(events.length).toBeGreaterThan(0);
|
|
|
|
await dbA.close();
|
|
await dbB.close();
|
|
});
|
|
});
|
|
|
|
// ===================================================================
|
|
// 回归:IndexedDB reopen 后 schema 持久化(v0.3.2 修复)
|
|
// ===================================================================
|
|
|
|
describe('[v0.3.2] KVStore reopen schema 持久化', () => {
|
|
test('close 后重新 open 表结构与数据完整', async () => {
|
|
const setup = new MetonaSqlark({ name: 'reopen-a', mode: 'hybrid', diskEngine: 'opfs' });
|
|
await setup.init();
|
|
await setup.defineTable('t', {
|
|
id: { type: 'string', primaryKey: true },
|
|
name: { type: 'string' },
|
|
v: { type: 'number' },
|
|
});
|
|
await setup.query(`INSERT INTO t VALUES ('1', 'Alice', 42)`);
|
|
await setup.close();
|
|
|
|
// 重新打开(模拟页面刷新)
|
|
const db = new MetonaSqlark({ name: 'reopen-a', version: 2, mode: 'hybrid', diskEngine: 'opfs' });
|
|
await db.init();
|
|
|
|
const schema = await db.getEngine().getTableSchema('t');
|
|
expect(schema?.columns.v).toBeDefined(); // 持久化 schema 保留完整列
|
|
|
|
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
|
expect(rows).toHaveLength(1);
|
|
expect(rows[0].name).toBe('Alice');
|
|
expect(rows[0].v).toBe(42);
|
|
|
|
// 重新打开后仍可写入并校验类型
|
|
await db.query(`INSERT INTO t VALUES ('2', 'Bob', 30)`);
|
|
await expect(db.query(`INSERT INTO t VALUES ('3', 'Bad', 'not-a-number')`)).rejects.toBeDefined();
|
|
await db.close();
|
|
});
|
|
|
|
test('reopen 后 UPDATE 全列生效', async () => {
|
|
const setup = new MetonaSqlark({ name: 'reopen-b', mode: 'hybrid', diskEngine: 'opfs' });
|
|
await setup.init();
|
|
await setup.defineTable('t', {
|
|
id: { type: 'string', primaryKey: true },
|
|
name: { type: 'string' },
|
|
v: { type: 'number' },
|
|
});
|
|
await setup.query(`INSERT INTO t VALUES ('1', 'Alice', 42)`);
|
|
await setup.close();
|
|
|
|
const db = new MetonaSqlark({ name: 'reopen-b', version: 2, mode: 'hybrid', diskEngine: 'opfs' });
|
|
await db.init();
|
|
await db.query(`UPDATE t SET name = 'Renamed', v = 99 WHERE id = '1'`);
|
|
|
|
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
|
expect(rows[0].name).toBe('Renamed');
|
|
expect(rows[0].v).toBe(99);
|
|
await db.close();
|
|
});
|
|
|
|
});
|