/** * 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 = {}) { 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[]; 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[]; 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[]; 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[]; 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[]; 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[]; 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[]; 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[]; 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[]; 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[]; // 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[]; // 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[]; // 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[]; 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 = 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[]; 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[]; 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[]; expect(rows[0].name).toBe('Renamed'); expect(rows[0].v).toBe(99); await db.close(); }); });