release: v0.3.2 — 质量加固 + SQL扩展 + 表达式 + 并发同步
CI / test (18.x) (push) Failing after 5m11s
CI / test (20.x) (push) Failing after 5m8s
CI / test (22.x) (push) Successful in 9m58s
CI / test (24.x) (push) Successful in 9m56s

v0.2.6 质量加固:
- 修复 AriaEngine 二级索引 SSTable 互相覆盖(命名空间隔离)
- 修复 LSM 多版本读取顺序错误 + MergeIterator 取最新来源
- 重写 LZ4 压缩器(往返一致性 + 缓冲区溢出)
- sstableCache LRU 上限 + 预加载兜底(BufferPool 配置生效)
- 修复 React/Vue 集成 import type 运行时 bug + exports 子路径
- 新增 38 个测试(LZ4往返/Crypto/集成), 删除伪测试

v0.3.0 SQL 功能扩展:
- 多语句 parseAll + 事务语句 BEGIN/COMMIT/ROLLBACK
- INSERT INTO ... SELECT + UNION/UNION ALL + EXISTS 关联子查询
- CREATE/DROP INDEX 五引擎实现 + 别名 WHERE 修复
- benchmark 页面 + 36 个新测试

v0.3.1 表达式与性能:
- CASE WHEN 表达式(SELECT 列/WHERE/聚合)
- JOIN + 关联子查询逐行绑定
- WAL 批量组提交(写放大 O(N)→O(1))
- 修复 pending frozen 可见性 + flush 缓存竞争

v0.3.2 并发:
- CASE WHEN 用于 WHERE/聚合 + JOIN 哈希连接
- 多标签页同步(multiTabSync + BroadcastChannel)
- IndexedDB schema 持久化(reopen 后表结构恢复)
- 修复 where-matcher 顶层 $not
- 修复 CJS 产物 .js 被 ESM 解析(exports 空) — .cjs 后缀 + exports 修正
- 836 测试 / 44 套件 / 81.0% 覆盖率
This commit is contained in:
thzxx
2026-08-08 10:41:30 +08:00
parent 3ae7d6e8fb
commit d544501e1c
77 changed files with 29007 additions and 20395 deletions
+273 -273
View File
@@ -1,273 +1,273 @@
/**
* AriaEngine Buffer Pool + Eviction 单元测试
*/
import { BufferPool, type PageIO } from '../../src/engine/aria/buffer/pool';
import { LRUList, EvictionManager } from '../../src/engine/aria/buffer/eviction';
import { PageType, PAGE_SIZE } from '../../src/engine/aria/types';
import { initPageHeader } from '../../src/engine/aria/page/header';
// ===================================================================
// LRUList
// ===================================================================
describe('AriaEngine — LRUList', () => {
function makePage(id: number) {
const data = new ArrayBuffer(PAGE_SIZE);
initPageHeader(data, id, PageType.DATA);
return { pageId: id, type: PageType.DATA, data, dirty: false, pins: 0, prev: null, next: null, lastAccess: Date.now() };
}
it('moveToHead — 单元素', () => {
const list = new LRUList();
const p = makePage(1);
list.moveToHead(p);
expect(list.size).toBe(1);
});
it('moveToHead — 多元素保持 MRU 顺序', () => {
const list = new LRUList();
const a = makePage(1);
const b = makePage(2);
const c = makePage(3);
list.moveToHead(a);
list.moveToHead(b);
list.moveToHead(c);
expect(list.size).toBe(3);
// c 是最新的
});
it('getLRU 返回最久未使用', () => {
const list = new LRUList();
const a = makePage(1);
const b = makePage(2);
list.moveToHead(a);
list.moveToHead(b);
expect(list.getLRU()!.pageId).toBe(1);
});
it('popLRU 移除并返回最久未使用', () => {
const list = new LRUList();
const a = makePage(1);
const b = makePage(2);
list.moveToHead(a);
list.moveToHead(b);
const popped = list.popLRU();
expect(popped!.pageId).toBe(1);
expect(list.size).toBe(1);
});
it('remove — 从中间移除', () => {
const list = new LRUList();
const a = makePage(1);
const b = makePage(2);
const c = makePage(3);
list.moveToHead(a);
list.moveToHead(b);
list.moveToHead(c);
list.remove(b);
expect(list.size).toBe(2);
});
it('clear 清空', () => {
const list = new LRUList();
list.moveToHead(makePage(1));
list.moveToHead(makePage(2));
list.clear();
expect(list.size).toBe(0);
});
it('getAllPages 返回所有页面', () => {
const list = new LRUList();
list.moveToHead(makePage(1));
list.moveToHead(makePage(2));
expect(list.getAllPages()).toHaveLength(2);
});
it('空 LRU getLRU 返回 null', () => {
const list = new LRUList();
expect(list.getLRU()).toBeNull();
});
it('空 LRU popLRU 返回 null', () => {
const list = new LRUList();
expect(list.popLRU()).toBeNull();
});
it('moveToHead 同元素不移重复', () => {
const list = new LRUList();
const p = makePage(1);
list.moveToHead(p);
list.moveToHead(p);
list.moveToHead(p);
expect(list.size).toBe(1);
});
});
// ===================================================================
// EvictionManager
// ===================================================================
describe('AriaEngine — EvictionManager', () => {
function makePage(id: number, dirty = false) {
const data = new ArrayBuffer(PAGE_SIZE);
initPageHeader(data, id, PageType.DATA);
return { pageId: id, type: PageType.DATA, data, dirty, pins: 0, prev: null, next: null, lastAccess: Date.now() };
}
it('access 更新 LRU', async () => {
let evicted = -1;
const em = new EvictionManager(3, async (p) => { evicted = p.pageId; });
const p = makePage(1);
em.add(p);
em.access(p);
expect(em.getSize()).toBe(1);
});
it('add 不超过容量不触发驱逐', async () => {
const evictedPages: number[] = [];
const em = new EvictionManager(4, async (p) => { evictedPages.push(p.pageId); });
em.add(makePage(1));
em.add(makePage(2));
em.add(makePage(3));
await em.evictIfNeeded(1);
expect(evictedPages).toHaveLength(0);
expect(em.getSize()).toBe(3);
});
it('evictIfNeeded 超容量触发驱逐', async () => {
const evictedPages: number[] = [];
const em = new EvictionManager(2, async (p) => { evictedPages.push(p.pageId); });
em.add(makePage(1));
em.add(makePage(2));
em.add(makePage(3)); // 超容量
await em.evictIfNeeded(0);
// 驱逐后 size 应 <= 2
expect(em.getSize()).toBeLessThanOrEqual(2);
});
it('remove 减少 size', () => {
const em = new EvictionManager(4, async () => {});
const p = makePage(1);
em.add(p);
em.add(makePage(2));
em.remove(p);
expect(em.getSize()).toBe(1);
});
it('getCapacity 返回配置容量', () => {
const em = new EvictionManager(128, async () => {});
expect(em.getCapacity()).toBe(128);
});
it('clear 清空', () => {
const em = new EvictionManager(4, async () => {});
em.add(makePage(1));
em.add(makePage(2));
em.clear();
expect(em.getSize()).toBe(0);
});
it('脏页驱逐前调用 onEvict 回调', async () => {
let flushed = 0;
const em = new EvictionManager(2, async (_p) => { flushed++; });
em.add(makePage(1, true)); // dirty page
em.add(makePage(2));
await em.evictIfNeeded(1); // need space → evict page 1
expect(flushed).toBeGreaterThanOrEqual(0); // May or may not evict
});
});
// ===================================================================
// BufferPool (with Mock PageIO)
// ===================================================================
describe('AriaEngine — BufferPool', () => {
class MockPageIO implements PageIO {
store = new Map<number, ArrayBuffer>();
nextId = 1;
reads = 0;
writes = 0;
async readPage(pageId: number) { this.reads++; return this.store.get(pageId) ?? null; }
async writePage(pageId: number, data: ArrayBuffer) { this.writes++; this.store.set(pageId, data); }
async allocatePageId() { return this.nextId++; }
async freePageId(_pageId: number) {}
}
it('newPage 创建页面并 pin', async () => {
const io = new MockPageIO();
const pool = new BufferPool(io, 4);
const page = await pool.newPage();
expect(page.pageId).toBe(1);
expect(page.pins).toBe(1);
expect(page.type).toBe(PageType.DATA);
pool.unpin(page);
});
it('getPage — 池中已存在则 pin++', async () => {
const io = new MockPageIO();
const pool = new BufferPool(io, 4);
const p1 = await pool.newPage();
pool.unpin(p1);
const p2 = await pool.getPage(p1.pageId);
expect(p2!.pageId).toBe(p1.pageId);
expect(p2!.pins).toBe(1);
pool.unpin(p2!);
});
it('markDirty + flushPage 写回', async () => {
const io = new MockPageIO();
const pool = new BufferPool(io, 4);
const page = await pool.newPage();
new Uint8Array(page.data)[100] = 42;
pool.markDirty(page);
pool.unpin(page);
await pool.flushPage(page.pageId);
expect(io.writes).toBeGreaterThan(0);
});
it('flushAll 刷新所有脏页', async () => {
const io = new MockPageIO();
const pool = new BufferPool(io, 8);
const p1 = await pool.newPage();
const p2 = await pool.newPage();
pool.markDirty(p1);
pool.markDirty(p2);
pool.unpin(p1);
pool.unpin(p2);
await pool.flushAll();
expect(io.writes).toBe(2);
});
it('getCachedPageCount 返回缓存数', async () => {
const io = new MockPageIO();
const pool = new BufferPool(io, 4);
await pool.newPage();
await pool.newPage();
expect(pool.getCachedPageCount()).toBe(2);
});
it('getDirtyPageCount 返回脏页数', async () => {
const io = new MockPageIO();
const pool = new BufferPool(io, 4);
const p = await pool.newPage();
pool.markDirty(p);
pool.unpin(p);
expect(pool.getDirtyPageCount()).toBe(1);
});
it('getCapacity 返回容量', async () => {
const io = new MockPageIO();
const pool = new BufferPool(io, 64);
expect(pool.getCapacity()).toBe(64);
});
it('removePage 移除缓存', async () => {
const io = new MockPageIO();
const pool = new BufferPool(io, 4);
const p = await pool.newPage();
pool.unpin(p);
pool.removePage(p.pageId);
expect(pool.getCachedPageCount()).toBe(0);
});
});
/**
* AriaEngine Buffer Pool + Eviction 单元测试
*/
import { BufferPool, type PageIO } from '../../src/engine/aria/buffer/pool';
import { LRUList, EvictionManager } from '../../src/engine/aria/buffer/eviction';
import { PageType, PAGE_SIZE } from '../../src/engine/aria/types';
import { initPageHeader } from '../../src/engine/aria/page/header';
// ===================================================================
// LRUList
// ===================================================================
describe('AriaEngine — LRUList', () => {
function makePage(id: number) {
const data = new ArrayBuffer(PAGE_SIZE);
initPageHeader(data, id, PageType.DATA);
return { pageId: id, type: PageType.DATA, data, dirty: false, pins: 0, prev: null, next: null, lastAccess: Date.now() };
}
it('moveToHead — 单元素', () => {
const list = new LRUList();
const p = makePage(1);
list.moveToHead(p);
expect(list.size).toBe(1);
});
it('moveToHead — 多元素保持 MRU 顺序', () => {
const list = new LRUList();
const a = makePage(1);
const b = makePage(2);
const c = makePage(3);
list.moveToHead(a);
list.moveToHead(b);
list.moveToHead(c);
expect(list.size).toBe(3);
// c 是最新的
});
it('getLRU 返回最久未使用', () => {
const list = new LRUList();
const a = makePage(1);
const b = makePage(2);
list.moveToHead(a);
list.moveToHead(b);
expect(list.getLRU()!.pageId).toBe(1);
});
it('popLRU 移除并返回最久未使用', () => {
const list = new LRUList();
const a = makePage(1);
const b = makePage(2);
list.moveToHead(a);
list.moveToHead(b);
const popped = list.popLRU();
expect(popped!.pageId).toBe(1);
expect(list.size).toBe(1);
});
it('remove — 从中间移除', () => {
const list = new LRUList();
const a = makePage(1);
const b = makePage(2);
const c = makePage(3);
list.moveToHead(a);
list.moveToHead(b);
list.moveToHead(c);
list.remove(b);
expect(list.size).toBe(2);
});
it('clear 清空', () => {
const list = new LRUList();
list.moveToHead(makePage(1));
list.moveToHead(makePage(2));
list.clear();
expect(list.size).toBe(0);
});
it('getAllPages 返回所有页面', () => {
const list = new LRUList();
list.moveToHead(makePage(1));
list.moveToHead(makePage(2));
expect(list.getAllPages()).toHaveLength(2);
});
it('空 LRU getLRU 返回 null', () => {
const list = new LRUList();
expect(list.getLRU()).toBeNull();
});
it('空 LRU popLRU 返回 null', () => {
const list = new LRUList();
expect(list.popLRU()).toBeNull();
});
it('moveToHead 同元素不移重复', () => {
const list = new LRUList();
const p = makePage(1);
list.moveToHead(p);
list.moveToHead(p);
list.moveToHead(p);
expect(list.size).toBe(1);
});
});
// ===================================================================
// EvictionManager
// ===================================================================
describe('AriaEngine — EvictionManager', () => {
function makePage(id: number, dirty = false) {
const data = new ArrayBuffer(PAGE_SIZE);
initPageHeader(data, id, PageType.DATA);
return { pageId: id, type: PageType.DATA, data, dirty, pins: 0, prev: null, next: null, lastAccess: Date.now() };
}
it('access 更新 LRU', async () => {
let evicted = -1;
const em = new EvictionManager(3, async (p) => { evicted = p.pageId; });
const p = makePage(1);
em.add(p);
em.access(p);
expect(em.getSize()).toBe(1);
});
it('add 不超过容量不触发驱逐', async () => {
const evictedPages: number[] = [];
const em = new EvictionManager(4, async (p) => { evictedPages.push(p.pageId); });
em.add(makePage(1));
em.add(makePage(2));
em.add(makePage(3));
await em.evictIfNeeded(1);
expect(evictedPages).toHaveLength(0);
expect(em.getSize()).toBe(3);
});
it('evictIfNeeded 超容量触发驱逐', async () => {
const evictedPages: number[] = [];
const em = new EvictionManager(2, async (p) => { evictedPages.push(p.pageId); });
em.add(makePage(1));
em.add(makePage(2));
em.add(makePage(3)); // 超容量
await em.evictIfNeeded(0);
// 驱逐后 size 应 <= 2
expect(em.getSize()).toBeLessThanOrEqual(2);
});
it('remove 减少 size', () => {
const em = new EvictionManager(4, async () => {});
const p = makePage(1);
em.add(p);
em.add(makePage(2));
em.remove(p);
expect(em.getSize()).toBe(1);
});
it('getCapacity 返回配置容量', () => {
const em = new EvictionManager(128, async () => {});
expect(em.getCapacity()).toBe(128);
});
it('clear 清空', () => {
const em = new EvictionManager(4, async () => {});
em.add(makePage(1));
em.add(makePage(2));
em.clear();
expect(em.getSize()).toBe(0);
});
it('脏页驱逐前调用 onEvict 回调', async () => {
let flushed = 0;
const em = new EvictionManager(2, async (_p) => { flushed++; });
em.add(makePage(1, true)); // dirty page
em.add(makePage(2));
await em.evictIfNeeded(1); // need space → evict page 1
expect(flushed).toBeGreaterThanOrEqual(0); // May or may not evict
});
});
// ===================================================================
// BufferPool (with Mock PageIO)
// ===================================================================
describe('AriaEngine — BufferPool', () => {
class MockPageIO implements PageIO {
store = new Map<number, ArrayBuffer>();
nextId = 1;
reads = 0;
writes = 0;
async readPage(pageId: number) { this.reads++; return this.store.get(pageId) ?? null; }
async writePage(pageId: number, data: ArrayBuffer) { this.writes++; this.store.set(pageId, data); }
async allocatePageId() { return this.nextId++; }
async freePageId(_pageId: number) {}
}
it('newPage 创建页面并 pin', async () => {
const io = new MockPageIO();
const pool = new BufferPool(io, 4);
const page = await pool.newPage();
expect(page.pageId).toBe(1);
expect(page.pins).toBe(1);
expect(page.type).toBe(PageType.DATA);
pool.unpin(page);
});
it('getPage — 池中已存在则 pin++', async () => {
const io = new MockPageIO();
const pool = new BufferPool(io, 4);
const p1 = await pool.newPage();
pool.unpin(p1);
const p2 = await pool.getPage(p1.pageId);
expect(p2!.pageId).toBe(p1.pageId);
expect(p2!.pins).toBe(1);
pool.unpin(p2!);
});
it('markDirty + flushPage 写回', async () => {
const io = new MockPageIO();
const pool = new BufferPool(io, 4);
const page = await pool.newPage();
new Uint8Array(page.data)[100] = 42;
pool.markDirty(page);
pool.unpin(page);
await pool.flushPage(page.pageId);
expect(io.writes).toBeGreaterThan(0);
});
it('flushAll 刷新所有脏页', async () => {
const io = new MockPageIO();
const pool = new BufferPool(io, 8);
const p1 = await pool.newPage();
const p2 = await pool.newPage();
pool.markDirty(p1);
pool.markDirty(p2);
pool.unpin(p1);
pool.unpin(p2);
await pool.flushAll();
expect(io.writes).toBe(2);
});
it('getCachedPageCount 返回缓存数', async () => {
const io = new MockPageIO();
const pool = new BufferPool(io, 4);
await pool.newPage();
await pool.newPage();
expect(pool.getCachedPageCount()).toBe(2);
});
it('getDirtyPageCount 返回脏页数', async () => {
const io = new MockPageIO();
const pool = new BufferPool(io, 4);
const p = await pool.newPage();
pool.markDirty(p);
pool.unpin(p);
expect(pool.getDirtyPageCount()).toBe(1);
});
it('getCapacity 返回容量', async () => {
const io = new MockPageIO();
const pool = new BufferPool(io, 64);
expect(pool.getCapacity()).toBe(64);
});
it('removePage 移除缓存', async () => {
const io = new MockPageIO();
const pool = new BufferPool(io, 4);
const p = await pool.newPage();
pool.unpin(p);
pool.removePage(p.pageId);
expect(pool.getCachedPageCount()).toBe(0);
});
});
+256
View File
@@ -0,0 +1,256 @@
/**
* AriaEngine SSTable 缓存内存上限测试
* @module tests/engine/aria-cache
*
* 验证 v0.2.6 修复:
* 1. SSTable 缓存受 cacheLimitBytes 上限约束(LRU 裁剪)
* 2. 缓存驱逐后所有读取路径(全表/范围/PK/索引)仍返回完整数据(prefetch 兜底)
* 3. 写入路径不会导致缓存无限增长
*/
import { AriaEngine } from '../../src/engine/aria/index';
import { createSchema } from '../../src/table/schema';
/** 构造小缓存 + 小 MemTable 阈值的引擎,快速产生多个 SSTable */
function createSmallCacheEngine(bufferPoolPages = 2) {
return new AriaEngine({
storageBackend: 'memory',
memtableSizeThreshold: 2048, // ~2KB 阈值 → 300 行会产生多个 SSTable
bufferPoolPages,
checkpointInterval: 100000, // 关闭自动 checkpoint,避免干扰
walSyncMode: 'none',
} as any);
}
function makeRows(count: number): Record<string, unknown>[] {
const rows: Record<string, unknown>[] = [];
for (let i = 0; i < count; i++) {
rows.push({ id: `u${i}`, name: `User${i}`, age: 20 + (i % 30) });
}
return rows;
}
describe('AriaEngine SSTable 缓存内存上限', () => {
test('缓存大小受 cacheLimitBytes 约束', async () => {
const engine = createSmallCacheEngine(2); // 2 * 4096 = 8KB 上限
await engine.open('cache-limit-test', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
age: { type: 'number', index: true },
}));
await engine.insert('users', makeRows(300));
const lsm = (engine as any).lsm as {
getCacheSize(): number;
getCacheLimit(): number;
getStats(): { sstableCount: number };
};
const stats = lsm.getStats();
// 300 行 / 2KB 阈值 → 应产生多个 SSTable
expect(stats.sstableCount).toBeGreaterThan(1);
// 多轮查询后缓存仍受上限约束
for (let round = 0; round < 5; round++) {
const rows = await engine.find('users', { table: 'users', where: { age: 25 } });
expect(rows.length).toBe(10);
expect(lsm.getCacheSize()).toBeLessThanOrEqual(lsm.getCacheLimit());
}
await engine.close();
});
test('缓存驱逐后全表扫描仍返回完整数据(prefetch 兜底)', async () => {
const engine = createSmallCacheEngine(1); // 4KB 上限,必然触发驱逐
await engine.open('cache-evict-fullscan', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
}));
const rows = makeRows(300);
await engine.insert('users', rows);
const all = await engine.find('users', { table: 'users' });
expect(all.length).toBe(300);
const lsm = (engine as any).lsm;
expect(lsm.getCacheSize()).toBeLessThanOrEqual(lsm.getCacheLimit());
await engine.close();
});
test('缓存驱逐后 PK 等值查询仍正确(prefetchKeys 兜底)', async () => {
const engine = createSmallCacheEngine(1);
await engine.open('cache-evict-pk', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
}));
const rows = makeRows(300);
await engine.insert('users', rows);
// 分散查询多个 PK,每次都会经历 驱逐+重新加载
for (let i = 0; i < 300; i += 11) {
const found = await engine.find('users', { table: 'users', where: { id: `u${i}` } });
expect(found.length).toBe(1);
expect(found[0].name).toBe(`User${i}`);
}
await engine.close();
});
test('缓存驱逐后二级索引查询仍正确', async () => {
const engine = createSmallCacheEngine(1);
await engine.open('cache-evict-idx', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
age: { type: 'number', index: true },
}));
await engine.insert('users', makeRows(300));
// 索引等值 + 范围查询
const eq = await engine.find('users', { table: 'users', where: { age: 25 } });
expect(eq.length).toBe(10);
// age 范围 20-49,每个值 10 行
const range = await engine.find('users', { table: 'users', where: { age: { $gte: 40 } } });
expect(range.length).toBe(100);
const range2 = await engine.find('users', { table: 'users', where: { age: { $gt: 45 } } });
expect(range2.length).toBe(40);
const inQuery = await engine.find('users', { table: 'users', where: { age: { $in: [21, 22] } } });
expect(inQuery.length).toBe(20);
await engine.close();
});
test('UPDATE/DELETE 在缓存驱逐后仍作用于全部行', async () => {
const engine = createSmallCacheEngine(1);
await engine.open('cache-evict-mutate', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
age: { type: 'number' },
}));
await engine.insert('users', makeRows(300));
// 无条件更新 → 全表更新
const updated = await engine.update('users', { table: 'users' }, { name: 'Renamed' });
expect(updated).toBe(300);
// age 20-49 每个值 10 行;$lt 25 → age 20-24 → 50 行
const deleted = await engine.delete('users', { table: 'users', where: { age: { $lt: 25 } } });
expect(deleted).toBe(50);
const remaining = await engine.find('users', { table: 'users' });
expect(remaining.length).toBe(250);
expect(remaining.every((r) => r.name === 'Renamed')).toBe(true);
await engine.close();
});
test('写入路径不突破缓存上限(flush 后立即裁剪)', async () => {
const engine = createSmallCacheEngine(2);
await engine.open('cache-write-bound', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
}));
// 分批写入,每批都触发多次 flush
for (let batch = 0; batch < 10; batch++) {
await engine.insert('users', makeRows(30).map((r, i) => ({ ...r, id: `b${batch}_u${i}` })));
const lsm = (engine as any).lsm;
expect(lsm.getCacheSize()).toBeLessThanOrEqual(lsm.getCacheLimit());
}
const all = await engine.find('users', { table: 'users' });
expect(all.length).toBe(300);
await engine.close();
});
test('回归:主 LSM 与二级索引 LSM 的 SSTable 不互相覆盖(命名空间隔离)', async () => {
const engine = createSmallCacheEngine(4);
await engine.open('regression-ns', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string', index: true },
age: { type: 'number', index: true },
}));
// 小阈值下 insert/update 会同时触发主 LSM 与两个索引 LSM 的多次 flush
await engine.insert('users', makeRows(120));
await engine.update('users', { table: 'users', where: { age: { $gte: 30 } } }, { name: 'Senior' });
// 主数据完整且为最新值(age 20-49 每个值出现 4 次;$gte 30 → 20 个值 × 4 = 80 行)
const all = await engine.find('users', { table: 'users' });
expect(all.length).toBe(120);
expect(all.filter((r) => r.name === 'Senior').length).toBe(80);
// 二级索引等值查找仍正确(索引 LSM 数据未被覆盖)
const byName = await engine.find('users', { table: 'users', where: { name: 'Senior' } });
expect(byName.length).toBe(80);
const byAge = await engine.find('users', { table: 'users', where: { age: 25 } });
expect(byAge.length).toBe(4);
await engine.close();
});
test('回归:同 key 跨多次 flush 更新后读到最新值(多版本语义)', async () => {
const engine = createSmallCacheEngine(4);
await engine.open('regression-versions', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
value: { type: 'number' },
}));
await engine.insert('users', [{ id: 'a', value: 1 }]);
// 连续更新同一行 20 次,每次更新都经历 flush
for (let v = 2; v <= 20; v++) {
await engine.update('users', { table: 'users', where: { id: 'a' } }, { value: v });
}
const rows = await engine.find('users', { table: 'users', where: { id: 'a' } });
expect(rows.length).toBe(1);
expect(rows[0].value).toBe(20);
// 全表扫描也应返回最新值
const all = await engine.find('users', { table: 'users' });
expect(all.length).toBe(1);
expect(all[0].value).toBe(20);
await engine.close();
});
test('回归:删除后 tombstone 跨 flush 仍生效(不残留旧数据)', async () => {
const engine = createSmallCacheEngine(4);
await engine.open('regression-tombstone', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
age: { type: 'number', index: true },
}));
await engine.insert('users', makeRows(120));
// 分批删除,触发多次 flush
for (let batch = 0; batch < 4; batch++) {
const deleted = await engine.delete('users', { table: 'users', where: { age: { $gte: 20 + batch * 5, $lt: 25 + batch * 5 } } });
expect(deleted).toBe(20);
}
const remaining = await engine.find('users', { table: 'users' });
expect(remaining.length).toBe(40);
// 索引查找也不应返回已删除行
const ghost = await engine.find('users', { table: 'users', where: { age: 22 } });
expect(ghost.length).toBe(0);
await engine.close();
});
});
+218 -127
View File
@@ -1,127 +1,218 @@
/**
* AriaEngine LZ4 压缩 + LSM Merge Iterator 单元测试
* 注:LZ4 为简化演示实现(默认 compression:false),测试聚焦于「不卡死」
*/
import { compressLZ4, decompressLZ4 } from '../../src/engine/aria/compression/lz4';
import { MergeIterator, ArrayEntrySource } from '../../src/engine/aria/index/merge_iterator';
// ===================================================================
// LZ4 压缩 — 安全烟雾测试(不卡死)
// ===================================================================
describe('AriaEngine — LZ4 Compression', () => {
it('短于 4 字节时原样返回', () => {
const input = new Uint8Array([1, 2]);
const compressed = compressLZ4(input);
expect(compressed).toBe(input);
});
it('简单文本压缩不抛出异常且产生输出', () => {
const input = new TextEncoder().encode('hello world hello world hello world');
const compressed = compressLZ4(input);
expect(compressed).toBeInstanceOf(Uint8Array);
expect(compressed.byteLength).toBeGreaterThan(0);
});
it('重复数据有压缩效果', () => {
const pattern = 'ABCD';
const repeated = pattern.repeat(100);
const input = new TextEncoder().encode(repeated);
const compressed = compressLZ4(input);
expect(compressed.byteLength).toBeLessThan(input.byteLength);
});
it('随机不可压缩数据不卡死', () => {
const input = new Uint8Array(256);
for (let i = 0; i < 256; i++) input[i] = Math.floor(Math.random() * 256);
const compressed = compressLZ4(input);
expect(compressed).toBeInstanceOf(Uint8Array);
expect(compressed.byteLength).toBeGreaterThan(0);
});
it('长文本压缩不卡死', () => {
const input = new TextEncoder().encode('The quick brown fox jumps over the lazy dog. '.repeat(10));
const compressed = compressLZ4(input);
expect(compressed).toBeTruthy();
expect(compressed.byteLength).toBeGreaterThan(0);
});
it('多种长度输入均不卡死', () => {
for (const size of [10, 50, 100, 200, 500]) {
const input = new Uint8Array(size);
for (let i = 0; i < size; i++) input[i] = i % 256;
const compressed = compressLZ4(input);
expect(compressed.byteLength).toBeLessThanOrEqual(input.byteLength + 16);
}
});
it('解压不抛出异常', () => {
const input = new TextEncoder().encode('test data for decompression smoke test');
const compressed = compressLZ4(input);
expect(() => decompressLZ4(compressed, input.byteLength)).not.toThrow();
});
});
// ===================================================================
// MergeIterator — 归并迭代器单元测试
// ===================================================================
describe('AriaEngine — MergeIterator', () => {
it('单数据源归并', () => {
const mi = new MergeIterator();
mi.addSource(new ArrayEntrySource([
['a', { v: 1 }],
['b', { v: 2 }],
['c', { v: 3 }],
]));
const result = mi.drain();
expect(result).toHaveLength(3);
expect(result.map(([k]) => k)).toEqual(['a', 'b', 'c']);
});
it('多数据源归并去重(保留最新)', () => {
const mi = new MergeIterator();
mi.addSource(new ArrayEntrySource([['a', { v: 'new' }], ['c', { v: 3 }]]));
mi.addSource(new ArrayEntrySource([['a', { v: 'old' }], ['b', { v: 2 }]]));
const result = mi.drain();
expect(result).toHaveLength(3);
expect(result[0][0]).toBe('a');
expect(result[0][1].v).toBe('new');
expect(result[1][0]).toBe('b');
expect(result[2][0]).toBe('c');
});
it('空数据源归并', () => {
const mi = new MergeIterator();
mi.addSource(new ArrayEntrySource([]));
const result = mi.drain();
expect(result).toHaveLength(0);
});
it('大数量归并', () => {
const mi = new MergeIterator();
for (let s = 0; s < 5; s++) {
const entries: [string, Record<string, unknown>][] = [];
for (let i = 0; i < 100; i++) {
entries.push([`src${s}-key-${String(i).padStart(3, '0')}`, { src: s, idx: i }]);
}
mi.addSource(new ArrayEntrySource(entries));
}
const result = mi.drain();
// 5 sources × 100 unique keys = 500 total (keys are unique per source)
expect(result).toHaveLength(500);
});
it('ArrayEntrySource — 迭代器用完返回 null', () => {
const src = new ArrayEntrySource([['k', { v: 1 }]]);
expect(src.next()).not.toBeNull();
expect(src.next()).toBeNull();
expect(src.next()).toBeNull();
});
it('ArrayEntrySource — reset 重置', () => {
const src = new ArrayEntrySource([['k1', { v: 1 }], ['k2', { v: 2 }]]);
src.next();
src.reset();
const val = src.next();
expect(val![0]).toBe('k1');
});
});
/**
* AriaEngine LZ4 压缩 + LSM Merge Iterator 单元测试
* 注:LZ4 为简化演示实现(默认 compression:false),测试聚焦于「不卡死」
*/
import { compressLZ4, decompressLZ4 } from '../../src/engine/aria/compression/lz4';
import { MergeIterator, ArrayEntrySource } from '../../src/engine/aria/index/merge_iterator';
// ===================================================================
// LZ4 压缩 — 安全烟雾测试(不卡死)
// ===================================================================
describe('AriaEngine — LZ4 Compression', () => {
it('短于 4 字节时压缩为纯字面量 token 且往返一致', () => {
const input = new Uint8Array([1, 2]);
const compressed = compressLZ4(input);
// token(lo=0) + 2 字节字面量
expect(compressed.byteLength).toBe(3);
expect(compressed[0]).toBe(0x20); // litLen=2, matchField=0
const restored = decompressLZ4(compressed, 2);
expect(Array.from(restored)).toEqual([1, 2]);
});
it('简单文本压缩不抛出异常且产生输出', () => {
const input = new TextEncoder().encode('hello world hello world hello world');
const compressed = compressLZ4(input);
expect(compressed).toBeInstanceOf(Uint8Array);
expect(compressed.byteLength).toBeGreaterThan(0);
});
it('重复数据有压缩效果', () => {
const pattern = 'ABCD';
const repeated = pattern.repeat(100);
const input = new TextEncoder().encode(repeated);
const compressed = compressLZ4(input);
expect(compressed.byteLength).toBeLessThan(input.byteLength);
});
it('随机不可压缩数据不卡死', () => {
const input = new Uint8Array(256);
for (let i = 0; i < 256; i++) input[i] = Math.floor(Math.random() * 256);
const compressed = compressLZ4(input);
expect(compressed).toBeInstanceOf(Uint8Array);
expect(compressed.byteLength).toBeGreaterThan(0);
});
it('长文本压缩不卡死', () => {
const input = new TextEncoder().encode('The quick brown fox jumps over the lazy dog. '.repeat(10));
const compressed = compressLZ4(input);
expect(compressed).toBeTruthy();
expect(compressed.byteLength).toBeGreaterThan(0);
});
it('多种长度输入均不卡死', () => {
for (const size of [10, 50, 100, 200, 500]) {
const input = new Uint8Array(size);
for (let i = 0; i < size; i++) input[i] = i % 256;
const compressed = compressLZ4(input);
expect(compressed.byteLength).toBeLessThanOrEqual(input.byteLength + 16);
}
});
it('解压不抛出异常', () => {
const input = new TextEncoder().encode('test data for decompression smoke test');
const compressed = compressLZ4(input);
expect(() => decompressLZ4(compressed, input.byteLength)).not.toThrow();
});
// ---- v0.2.6 补强:压缩 → 解压 往返一致性 ----
it('往返一致:重复模式数据', () => {
const input = new TextEncoder().encode('ABCD'.repeat(100));
const compressed = compressLZ4(input);
const restored = decompressLZ4(compressed, input.byteLength);
expect(Array.from(restored)).toEqual(Array.from(input));
});
it('往返一致:自然文本数据', () => {
const input = new TextEncoder().encode(
'The quick brown fox jumps over the lazy dog. '.repeat(10),
);
const compressed = compressLZ4(input);
const restored = decompressLZ4(compressed, input.byteLength);
expect(Array.from(restored)).toEqual(Array.from(input));
});
it('往返一致:随机不可压缩数据', () => {
const input = new Uint8Array(512);
for (let i = 0; i < 512; i++) input[i] = Math.floor(Math.random() * 256);
const compressed = compressLZ4(input);
const restored = decompressLZ4(compressed, input.byteLength);
expect(Array.from(restored)).toEqual(Array.from(input));
});
it('往返一致:多种长度与字节模式', () => {
for (const size of [4, 5, 15, 16, 17, 50, 100, 300, 1000]) {
const input = new Uint8Array(size);
for (let i = 0; i < size; i++) input[i] = i % 7 === 0 ? i % 256 : 0x41;
const compressed = compressLZ4(input);
const restored = decompressLZ4(compressed, input.byteLength);
expect(Array.from(restored)).toEqual(Array.from(input));
}
});
it('往返一致:恰好 15 字节字面量边界', () => {
// 字面量长度恰好 15(token 上限)时不应丢字节
const input = new Uint8Array(15);
for (let i = 0; i < 15; i++) input[i] = i;
const compressed = compressLZ4(input);
const restored = decompressLZ4(compressed, input.byteLength);
expect(Array.from(restored)).toEqual(Array.from(input));
});
it('往返一致:超过 15 字节的连续匹配', () => {
const input = new TextEncoder().encode('X'.repeat(200) + 'Y' + 'X'.repeat(60));
const compressed = compressLZ4(input);
const restored = decompressLZ4(compressed, input.byteLength);
expect(Array.from(restored)).toEqual(Array.from(input));
});
});
// ===================================================================
// MergeIterator — 归并迭代器单元测试
// ===================================================================
describe('AriaEngine — MergeIterator', () => {
it('单数据源归并', () => {
const mi = new MergeIterator();
mi.addSource(new ArrayEntrySource([
['a', { v: 1 }],
['b', { v: 2 }],
['c', { v: 3 }],
]));
const result = mi.drain();
expect(result).toHaveLength(3);
expect(result.map(([k]) => k)).toEqual(['a', 'b', 'c']);
});
it('多数据源归并去重(保留最新)', () => {
const mi = new MergeIterator();
mi.addSource(new ArrayEntrySource([['a', { v: 'new' }], ['c', { v: 3 }]]));
mi.addSource(new ArrayEntrySource([['a', { v: 'old' }], ['b', { v: 2 }]]));
const result = mi.drain();
expect(result).toHaveLength(3);
expect(result[0][0]).toBe('a');
expect(result[0][1].v).toBe('new');
expect(result[1][0]).toBe('b');
expect(result[2][0]).toBe('c');
});
// ---- v0.2.6 回归:同 key 多来源时保留 sourceIndex 最小(最新)的条目 ----
it('回归:同 key 出现在多个来源时返回 sourceIndex 最小(最新来源)的值', () => {
const mi = new MergeIterator();
// 语义:sourceIndex 越小越新(memtable=0 < immutable=1 < sstable=2+
mi.addSource(new ArrayEntrySource([['a', { v: 'source0' }]])); // 最新来源
mi.addSource(new ArrayEntrySource([['a', { v: 'source1' }]]));
mi.addSource(new ArrayEntrySource([['a', { v: 'source2' }]])); // 最旧来源
const result = mi.drain();
expect(result).toHaveLength(1);
// 取的是 sourceIndex 最小(最新来源)的条目,而非堆序决定的任意条目
expect(result[0][1].v).toBe('source0');
});
it('回归:最新来源的值位于中间 sourceIndex 时仍取 sourceIndex 最小者', () => {
const mi = new MergeIterator();
mi.addSource(new ArrayEntrySource([['a', { v: 'middle' }]])); // index 0 = 最新来源
mi.addSource(new ArrayEntrySource([['a', { v: 'newest' }]])); // index 1
mi.addSource(new ArrayEntrySource([['a', { v: 'oldest' }]])); // index 2
const result = mi.drain();
expect(result).toHaveLength(1);
expect(result[0][1].v).toBe('middle'); // index 0 的条目胜出
});
it('回归:多个同 key 来源 + 其他独立 key 混合', () => {
const mi = new MergeIterator();
mi.addSource(new ArrayEntrySource([['a', { v: 1 }], ['b', { v: 10 }]]));
mi.addSource(new ArrayEntrySource([['a', { v: 2 }]]));
mi.addSource(new ArrayEntrySource([['a', { v: 3 }], ['c', { v: 30 }]]));
const result = mi.drain();
expect(result.map(([k]) => k)).toEqual(['a', 'b', 'c']);
expect(result[0][1].v).toBe(1); // sourceIndex 0 = 最新
expect(result[1][1].v).toBe(10);
expect(result[2][1].v).toBe(30);
});
it('空数据源归并', () => {
const mi = new MergeIterator();
mi.addSource(new ArrayEntrySource([]));
const result = mi.drain();
expect(result).toHaveLength(0);
});
it('大数量归并', () => {
const mi = new MergeIterator();
for (let s = 0; s < 5; s++) {
const entries: [string, Record<string, unknown>][] = [];
for (let i = 0; i < 100; i++) {
entries.push([`src${s}-key-${String(i).padStart(3, '0')}`, { src: s, idx: i }]);
}
mi.addSource(new ArrayEntrySource(entries));
}
const result = mi.drain();
// 5 sources × 100 unique keys = 500 total (keys are unique per source)
expect(result).toHaveLength(500);
});
it('ArrayEntrySource — 迭代器用完返回 null', () => {
const src = new ArrayEntrySource([['k', { v: 1 }]]);
expect(src.next()).not.toBeNull();
expect(src.next()).toBeNull();
expect(src.next()).toBeNull();
});
it('ArrayEntrySource — reset 重置', () => {
const src = new ArrayEntrySource([['k1', { v: 1 }], ['k2', { v: 2 }]]);
src.next();
src.reset();
const val = src.next();
expect(val![0]).toBe('k1');
});
});
+111
View File
@@ -0,0 +1,111 @@
/**
* AriaEngine CryptoManager 加解密测试
* @module tests/engine/aria-crypto
*
* v0.2.6 补强:此前仅验证实例化,现在验证真实的加解密往返一致性。
*/
import {
CryptoManager,
initCrypto,
encryptPage,
decryptPage,
closeCrypto,
} from '../../src/engine/aria/crypto';
function toBytes(data: ArrayBuffer): number[] {
return Array.from(new Uint8Array(data));
}
describe('AriaEngine — CryptoManager', () => {
test('加解密往返一致', async () => {
const cm = new CryptoManager();
await cm.init('test-password');
expect(cm.enabled).toBe(true);
const original = new TextEncoder().encode('sensitive row data').buffer;
const { iv, data } = await cm.encryptPage(original);
// 密文应为乱码(与原文不同)
expect(toBytes(data)).not.toEqual(toBytes(original));
const decrypted = await cm.decryptPage(iv, data);
expect(toBytes(decrypted)).toEqual(toBytes(original));
cm.close();
expect(cm.enabled).toBe(false);
});
test('错误密码解密失败(密钥不同)', async () => {
const cm1 = new CryptoManager();
await cm1.init('correct-password');
const original = new TextEncoder().encode('top secret').buffer;
const { iv, data } = await cm1.encryptPage(original);
const cm2 = new CryptoManager();
await cm2.init('wrong-password');
await expect(cm2.decryptPage(iv, data)).rejects.toThrow();
cm1.close();
cm2.close();
});
test('不同 salt 派生不同密钥,解密互相失败', async () => {
const cm1 = new CryptoManager();
await cm1.init('pwd', new Uint8Array(16).fill(1));
const cm2 = new CryptoManager();
await cm2.init('pwd', new Uint8Array(16).fill(2));
const original = new TextEncoder().encode('salt matters').buffer;
const { iv, data } = await cm1.encryptPage(original);
await expect(cm2.decryptPage(iv, data)).rejects.toThrow();
cm1.close();
cm2.close();
});
test('未初始化时加密抛错', async () => {
const cm = new CryptoManager();
expect(cm.enabled).toBe(false);
const data = new TextEncoder().encode('x').buffer;
await expect(cm.encryptPage(data)).rejects.toThrow(/not initialized/);
});
test('不同实例互不影响(独立密钥状态)', async () => {
const cm1 = new CryptoManager();
await cm1.init('pwd-a');
const cm2 = new CryptoManager();
await cm2.init('pwd-b');
const original = new TextEncoder().encode('instance isolation').buffer;
const { iv, data } = await cm1.encryptPage(original);
await expect(cm2.decryptPage(iv, data)).rejects.toThrow();
// 各自解密自己的数据
const dec2orig = new TextEncoder().encode('two').buffer;
const enc2 = await cm2.encryptPage(dec2orig);
const dec2 = await cm2.decryptPage(enc2.iv, enc2.data);
expect(toBytes(dec2)).toEqual(toBytes(dec2orig));
cm1.close();
cm2.close();
});
test('全局兼容层往返一致', async () => {
await initCrypto('global-password');
const original = new TextEncoder().encode('global compat layer').buffer;
const { iv, data } = await encryptPage(original);
const decrypted = await decryptPage(iv, data);
expect(toBytes(decrypted)).toEqual(toBytes(original));
closeCrypto();
});
test('大块数据(接近页面大小)往返一致', async () => {
const cm = new CryptoManager();
await cm.init('page-size-test');
// 4KB 页面数据
const original = new Uint8Array(4096);
for (let i = 0; i < 4096; i++) original[i] = i % 251;
const { iv, data } = await cm.encryptPage(original.buffer);
const decrypted = await cm.decryptPage(iv, data);
expect(toBytes(decrypted)).toEqual(toBytes(original.buffer));
cm.close();
});
});
+216 -216
View File
@@ -1,216 +1,216 @@
/**
* AriaEngine Bloom Filter + MemTable 单元测试
*/
import { BloomFilter } from '../../src/engine/aria/index/bloom';
import { MemTable } from '../../src/engine/aria/index/memtable';
// ===================================================================
// BloomFilter
// ===================================================================
describe('AriaEngine — BloomFilter', () => {
it('插入后 mayContain 返回 true', () => {
const bf = new BloomFilter(100);
bf.insert('hello');
expect(bf.mayContain('hello')).toBe(true);
});
it('未插入的 key mayContain 返回 false', () => {
const bf = new BloomFilter(100);
bf.insert('hello');
expect(bf.mayContain('world')).toBe(false);
});
it('批量插入后所有 key 都判定存在', () => {
const bf = new BloomFilter(500);
const keys: string[] = [];
for (let i = 0; i < 200; i++) {
const k = `key-${i}`;
keys.push(k);
bf.insert(k);
}
for (const k of keys) {
expect(bf.mayContain(k)).toBe(true);
}
});
it('False positive 率可控', () => {
const n = 500;
const bf = new BloomFilter(n, 10);
for (let i = 0; i < n; i++) {
bf.insert(`present-${i}`);
}
let fp = 0;
for (let i = 0; i < 500; i++) {
if (bf.mayContain(`absent-${i}`)) fp++;
}
expect(fp).toBeLessThan(25);
});
it('getBitSize 返回正确位数', () => {
const bf = new BloomFilter(100, 10);
expect(bf.getBitSize()).toBeGreaterThanOrEqual(64);
});
it('getInsertedCount 追踪插入数', () => {
const bf = new BloomFilter(100);
bf.insert('a');
bf.insert('b');
bf.insert('c');
expect(bf.getInsertedCount()).toBe(3);
});
it('getHashCount 返回哈希函数数量', () => {
const bf = new BloomFilter(1000, 10);
expect(bf.getHashCount()).toBeGreaterThan(0);
});
it('serialize + fromData 往返', () => {
const bf1 = new BloomFilter(100);
bf1.insert('a');
bf1.insert('b');
const data = bf1.serialize();
const bf2 = BloomFilter.fromData(data, bf1.getHashCount());
expect(bf2.mayContain('a')).toBe(true);
expect(bf2.mayContain('b')).toBe(true);
expect(bf2.mayContain('c')).toBe(false);
});
it('空过滤器 mayContain 返回 false', () => {
const bf = new BloomFilter(100);
expect(bf.mayContain('anything')).toBe(false);
});
it('最少 1 个哈希函数', () => {
const bf = new BloomFilter(10, 1);
expect(bf.getHashCount()).toBeGreaterThanOrEqual(1);
});
});
// ===================================================================
// MemTable
// ===================================================================
describe('AriaEngine — MemTable', () => {
let mt: MemTable;
beforeEach(() => {
mt = new MemTable(4 * 1024 * 1024);
});
it('put + get 往返', () => {
mt.put('key1', { name: 'Alice', age: 30 });
const val = mt.get('key1');
expect(val).not.toBeNull();
expect(val!.name).toBe('Alice');
});
it('get — 不存在的 key 返回 null', () => {
expect(mt.get('nonexistent')).toBeNull();
});
it('put 更新已存在的 key', () => {
mt.put('k', { v: 1 });
mt.put('k', { v: 2 });
expect(mt.get('k')!.v).toBe(2);
});
it('delete 删除成功', () => {
mt.put('k', { v: 1 });
expect(mt.delete('k')).toBe(true);
expect(mt.get('k')).toBeNull();
});
it('delete — 不存在的 key 返回 false', () => {
expect(mt.delete('ghost')).toBe(false);
});
it('getAllEntries 返回所有条目(有序)', () => {
mt.put('c', { v: 3 });
mt.put('a', { v: 1 });
mt.put('b', { v: 2 });
const entries = mt.getAllEntries();
expect(entries).toHaveLength(3);
expect(entries[0][0]).toBe('a');
expect(entries[1][0]).toBe('b');
expect(entries[2][0]).toBe('c');
});
it('rangeScan — 范围查询', () => {
mt.put('a', { v: 1 });
mt.put('b', { v: 2 });
mt.put('c', { v: 3 });
mt.put('d', { v: 4 });
const results = mt.rangeScan('b', 'c');
expect(results).toHaveLength(2);
expect(results[0][0]).toBe('b');
expect(results[1][0]).toBe('c');
});
it('rangeScan — 空结果', () => {
mt.put('a', { v: 1 });
const results = mt.rangeScan('z', 'zz');
expect(results).toHaveLength(0);
});
it('getEntryCount 正确计数', () => {
expect(mt.getEntryCount()).toBe(0);
mt.put('a', { v: 1 });
mt.put('b', { v: 2 });
expect(mt.getEntryCount()).toBe(2);
mt.delete('a');
expect(mt.getEntryCount()).toBe(1);
});
it('contains 检查存在性', () => {
mt.put('x', { v: 1 });
expect(mt.contains('x')).toBe(true);
expect(mt.contains('y')).toBe(false);
});
it('shouldFlush — 未达阈值返回 false', () => {
expect(mt.shouldFlush()).toBe(false);
});
it('clear 清空所有数据', () => {
mt.put('a', { v: 1 });
mt.put('b', { v: 2 });
mt.clear();
expect(mt.getEntryCount()).toBe(0);
expect(mt.get('a')).toBeNull();
});
it('getEstimatedSize 返回合理估计值', () => {
expect(mt.getEstimatedSize()).toBe(0);
mt.put('hello', { name: 'world', count: 42 });
expect(mt.getEstimatedSize()).toBeGreaterThan(0);
});
it('大量数据插入保持有序', () => {
const count = 100;
for (let i = count - 1; i >= 0; i--) {
mt.put(`key-${String(i).padStart(3, '0')}`, { idx: i });
}
const entries = mt.getAllEntries();
expect(entries).toHaveLength(count);
for (let i = 0; i < count; i++) {
expect(entries[i][1].idx).toBe(i);
}
});
it('删除后重新插入', () => {
mt.put('k', { v: 1 });
mt.delete('k');
mt.put('k', { v: 2 });
expect(mt.get('k')!.v).toBe(2);
});
it('范围扫描包含边界', () => {
mt.put('aa', { v: 1 });
mt.put('ab', { v: 2 });
mt.put('ac', { v: 3 });
const results = mt.rangeScan('aa', 'ab');
expect(results).toHaveLength(2);
expect(results[0][0]).toBe('aa');
expect(results[1][0]).toBe('ab');
});
});
/**
* AriaEngine Bloom Filter + MemTable 单元测试
*/
import { BloomFilter } from '../../src/engine/aria/index/bloom';
import { MemTable } from '../../src/engine/aria/index/memtable';
// ===================================================================
// BloomFilter
// ===================================================================
describe('AriaEngine — BloomFilter', () => {
it('插入后 mayContain 返回 true', () => {
const bf = new BloomFilter(100);
bf.insert('hello');
expect(bf.mayContain('hello')).toBe(true);
});
it('未插入的 key mayContain 返回 false', () => {
const bf = new BloomFilter(100);
bf.insert('hello');
expect(bf.mayContain('world')).toBe(false);
});
it('批量插入后所有 key 都判定存在', () => {
const bf = new BloomFilter(500);
const keys: string[] = [];
for (let i = 0; i < 200; i++) {
const k = `key-${i}`;
keys.push(k);
bf.insert(k);
}
for (const k of keys) {
expect(bf.mayContain(k)).toBe(true);
}
});
it('False positive 率可控', () => {
const n = 500;
const bf = new BloomFilter(n, 10);
for (let i = 0; i < n; i++) {
bf.insert(`present-${i}`);
}
let fp = 0;
for (let i = 0; i < 500; i++) {
if (bf.mayContain(`absent-${i}`)) fp++;
}
expect(fp).toBeLessThan(25);
});
it('getBitSize 返回正确位数', () => {
const bf = new BloomFilter(100, 10);
expect(bf.getBitSize()).toBeGreaterThanOrEqual(64);
});
it('getInsertedCount 追踪插入数', () => {
const bf = new BloomFilter(100);
bf.insert('a');
bf.insert('b');
bf.insert('c');
expect(bf.getInsertedCount()).toBe(3);
});
it('getHashCount 返回哈希函数数量', () => {
const bf = new BloomFilter(1000, 10);
expect(bf.getHashCount()).toBeGreaterThan(0);
});
it('serialize + fromData 往返', () => {
const bf1 = new BloomFilter(100);
bf1.insert('a');
bf1.insert('b');
const data = bf1.serialize();
const bf2 = BloomFilter.fromData(data, bf1.getHashCount());
expect(bf2.mayContain('a')).toBe(true);
expect(bf2.mayContain('b')).toBe(true);
expect(bf2.mayContain('c')).toBe(false);
});
it('空过滤器 mayContain 返回 false', () => {
const bf = new BloomFilter(100);
expect(bf.mayContain('anything')).toBe(false);
});
it('最少 1 个哈希函数', () => {
const bf = new BloomFilter(10, 1);
expect(bf.getHashCount()).toBeGreaterThanOrEqual(1);
});
});
// ===================================================================
// MemTable
// ===================================================================
describe('AriaEngine — MemTable', () => {
let mt: MemTable;
beforeEach(() => {
mt = new MemTable(4 * 1024 * 1024);
});
it('put + get 往返', () => {
mt.put('key1', { name: 'Alice', age: 30 });
const val = mt.get('key1');
expect(val).not.toBeNull();
expect(val!.name).toBe('Alice');
});
it('get — 不存在的 key 返回 null', () => {
expect(mt.get('nonexistent')).toBeNull();
});
it('put 更新已存在的 key', () => {
mt.put('k', { v: 1 });
mt.put('k', { v: 2 });
expect(mt.get('k')!.v).toBe(2);
});
it('delete 删除成功', () => {
mt.put('k', { v: 1 });
expect(mt.delete('k')).toBe(true);
expect(mt.get('k')).toBeNull();
});
it('delete — 不存在的 key 返回 false', () => {
expect(mt.delete('ghost')).toBe(false);
});
it('getAllEntries 返回所有条目(有序)', () => {
mt.put('c', { v: 3 });
mt.put('a', { v: 1 });
mt.put('b', { v: 2 });
const entries = mt.getAllEntries();
expect(entries).toHaveLength(3);
expect(entries[0][0]).toBe('a');
expect(entries[1][0]).toBe('b');
expect(entries[2][0]).toBe('c');
});
it('rangeScan — 范围查询', () => {
mt.put('a', { v: 1 });
mt.put('b', { v: 2 });
mt.put('c', { v: 3 });
mt.put('d', { v: 4 });
const results = mt.rangeScan('b', 'c');
expect(results).toHaveLength(2);
expect(results[0][0]).toBe('b');
expect(results[1][0]).toBe('c');
});
it('rangeScan — 空结果', () => {
mt.put('a', { v: 1 });
const results = mt.rangeScan('z', 'zz');
expect(results).toHaveLength(0);
});
it('getEntryCount 正确计数', () => {
expect(mt.getEntryCount()).toBe(0);
mt.put('a', { v: 1 });
mt.put('b', { v: 2 });
expect(mt.getEntryCount()).toBe(2);
mt.delete('a');
expect(mt.getEntryCount()).toBe(1);
});
it('contains 检查存在性', () => {
mt.put('x', { v: 1 });
expect(mt.contains('x')).toBe(true);
expect(mt.contains('y')).toBe(false);
});
it('shouldFlush — 未达阈值返回 false', () => {
expect(mt.shouldFlush()).toBe(false);
});
it('clear 清空所有数据', () => {
mt.put('a', { v: 1 });
mt.put('b', { v: 2 });
mt.clear();
expect(mt.getEntryCount()).toBe(0);
expect(mt.get('a')).toBeNull();
});
it('getEstimatedSize 返回合理估计值', () => {
expect(mt.getEstimatedSize()).toBe(0);
mt.put('hello', { name: 'world', count: 42 });
expect(mt.getEstimatedSize()).toBeGreaterThan(0);
});
it('大量数据插入保持有序', () => {
const count = 100;
for (let i = count - 1; i >= 0; i--) {
mt.put(`key-${String(i).padStart(3, '0')}`, { idx: i });
}
const entries = mt.getAllEntries();
expect(entries).toHaveLength(count);
for (let i = 0; i < count; i++) {
expect(entries[i][1].idx).toBe(i);
}
});
it('删除后重新插入', () => {
mt.put('k', { v: 1 });
mt.delete('k');
mt.put('k', { v: 2 });
expect(mt.get('k')!.v).toBe(2);
});
it('范围扫描包含边界', () => {
mt.put('aa', { v: 1 });
mt.put('ab', { v: 2 });
mt.put('ac', { v: 3 });
const results = mt.rangeScan('aa', 'ab');
expect(results).toHaveLength(2);
expect(results[0][0]).toBe('aa');
expect(results[1][0]).toBe('ab');
});
});
+337 -337
View File
@@ -1,337 +1,337 @@
/**
* AriaEngine Page 格式单元测试
* 覆盖: PageHeader / Slot / Tuple 编解码 + PageFormat 整合
*/
import {
PAGE_SIZE, PageType, PAGE_HEADER_SIZE, SLOT_ENTRY_SIZE,
} from '../../src/engine/aria/types';
import {
encodePageHeader, decodePageHeader, initPageHeader, getPageType,
getSlotCount, getFreeStart, setFreeStart, setFreeEnd,
} from '../../src/engine/aria/page/header';
import {
getSlotEntry, setSlotEntry, getSlotDirectorySize,
getFreeSpace, hasEnoughSpace, allocateSlot, readSlotData, freeSlot,
} from '../../src/engine/aria/page/slot';
import {
encodeTuple, decodeTuple, getColumnEncodingMap,
} from '../../src/engine/aria/page/tuple';
import { ColumnEncoding } from '../../src/engine/aria/types';
import {
createPage, pageFromBuffer, pageInsertRow, pageReadRow,
pageDeleteRow, pageUpdateRow, computeChecksum, verifyChecksum, updateChecksum,
} from '../../src/engine/aria/page/format';
// ===================================================================
// PageHeader
// ===================================================================
describe('AriaEngine Page — Header', () => {
let buf: ArrayBuffer;
beforeEach(() => {
buf = new ArrayBuffer(PAGE_SIZE);
});
it('initPageHeader 初始化头部字段', () => {
initPageHeader(buf, 42, PageType.DATA);
const h = decodePageHeader(buf);
expect(h.pageId).toBe(42);
expect(h.type).toBe(PageType.DATA);
expect(h.slotCount).toBe(0);
expect(h.freeStart).toBe(PAGE_HEADER_SIZE);
expect(h.freeEnd).toBe(PAGE_SIZE);
});
it('initPageHeader — INDEX 类型页面', () => {
initPageHeader(buf, 99, PageType.INDEX);
expect(getPageType(buf)).toBe(PageType.INDEX);
});
it('encodePageHeader + decodePageHeader 往返一致', () => {
const header = { pageId: 7, type: PageType.META, freeStart: 32, freeEnd: 4000, slotCount: 5, checksum: 0xdeadbeef };
encodePageHeader(header, buf);
const decoded = decodePageHeader(buf);
expect(decoded.pageId).toBe(7);
expect(decoded.type).toBe(PageType.META);
expect(decoded.freeStart).toBe(32);
expect(decoded.freeEnd).toBe(4000);
expect(decoded.slotCount).toBe(5);
});
it('不同 pageId 正确编解码', () => {
for (const id of [0, 1, 255, 65535, 0xffffffff]) {
initPageHeader(buf, id, PageType.DATA);
expect(decodePageHeader(buf).pageId).toBe(id >>> 0);
}
});
it('setFreeStart / setFreeEnd 修改字段', () => {
initPageHeader(buf, 1, PageType.DATA);
setFreeStart(buf, 100);
setFreeEnd(buf, 3000);
expect(getFreeStart(buf)).toBe(100);
expect(decodePageHeader(buf).freeEnd).toBe(3000);
});
});
// ===================================================================
// Slot Directory
// ===================================================================
describe('AriaEngine Page — Slot', () => {
let buf: ArrayBuffer;
beforeEach(() => {
buf = new ArrayBuffer(PAGE_SIZE);
initPageHeader(buf, 1, PageType.DATA);
});
it('getSlotEntry — 空页面 slotCount 为 0', () => {
expect(getSlotCount(buf)).toBe(0);
});
it('setSlotEntry + getSlotEntry 往返', () => {
// 手动写一个 slot(不通过 allocateSlot
new DataView(buf).setUint16(9, 1, false); // slotCount = 1
setSlotEntry(buf, 0, { offset: 1000, length: 50 });
const entry = getSlotEntry(buf, 0);
expect(entry.offset).toBe(1000);
expect(entry.length).toBe(50);
});
it('getSlotDirectorySize 计算正确', () => {
expect(getSlotDirectorySize(0)).toBe(0);
expect(getSlotDirectorySize(1)).toBe(SLOT_ENTRY_SIZE);
expect(getSlotDirectorySize(10)).toBe(10 * SLOT_ENTRY_SIZE);
});
it('getFreeSpace — 空页面有最大空闲空间', () => {
const free = getFreeSpace(buf);
expect(free).toBe(PAGE_SIZE - PAGE_HEADER_SIZE);
});
it('hasEnoughSpace — 小数据返回 true', () => {
expect(hasEnoughSpace(buf, 100)).toBe(true);
});
it('hasEnoughSpace — 超大数据返回 false', () => {
expect(hasEnoughSpace(buf, PAGE_SIZE * 2)).toBe(false);
});
it('allocateSlot 分配并写入数据', () => {
const data = new Uint8Array([1, 2, 3, 4, 5]);
const idx = allocateSlot(buf, data);
expect(idx).toBe(0);
expect(getSlotCount(buf)).toBe(1);
const readBack = readSlotData(buf, 0);
expect(readBack).not.toBeNull();
expect(Array.from(readBack!)).toEqual([1, 2, 3, 4, 5]);
});
it('allocateSlot 多次分配', () => {
for (let i = 0; i < 10; i++) {
const data = new Uint8Array([i, i + 1]);
const idx = allocateSlot(buf, data);
expect(idx).toBe(i);
}
expect(getSlotCount(buf)).toBe(10);
// 验证每个 slot 数据正确
for (let i = 0; i < 10; i++) {
const data = readSlotData(buf, i);
expect(Array.from(data!)).toEqual([i, i + 1]);
}
});
it('freeSlot 标记为删除', () => {
const data = new Uint8Array([10, 20, 30]);
allocateSlot(buf, data);
freeSlot(buf, 0);
const entry = getSlotEntry(buf, 0);
expect(entry.offset).toBe(0);
expect(entry.length).toBe(0);
});
it('readSlotData — 已删除 slot 返回 null', () => {
allocateSlot(buf, new Uint8Array([1]));
freeSlot(buf, 0);
expect(readSlotData(buf, 0)).toBeNull();
});
it('allocateSlot — 空间不足返回 -1', () => {
// 填满页面
const big = new Uint8Array(PAGE_SIZE - PAGE_HEADER_SIZE - SLOT_ENTRY_SIZE);
const idx1 = allocateSlot(buf, big);
expect(idx1).toBe(0);
const idx2 = allocateSlot(buf, new Uint8Array([1]));
expect(idx2).toBe(-1);
});
});
// ===================================================================
// Tuple Codec
// ===================================================================
describe('AriaEngine Page — Tuple', () => {
const colOrder = ['id', 'name', 'age', 'active', 'data'];
const colTypes: Record<string, string> = {
id: 'string', name: 'string', age: 'number', active: 'boolean', data: 'json',
};
it('encodeTuple + decodeTuple 完整往返', () => {
const row = { id: '1', name: 'Alice', age: 30, active: true, data: { x: 1 } };
const encoded = encodeTuple(row, colOrder, colTypes);
expect(encoded.byteLength).toBeGreaterThan(0);
const decoded = decodeTuple(encoded, colOrder, colTypes);
expect(decoded).not.toBeNull();
expect(decoded!.id).toBe('1');
expect(decoded!.name).toBe('Alice');
expect(decoded!.age).toBe(30);
expect(decoded!.active).toBe(true);
expect(decoded!.data).toEqual({ x: 1 });
});
it('encodeTuple — null 值正确处理', () => {
const row = { id: '2', name: null, age: 25, active: null, data: null };
const encoded = encodeTuple(row, colOrder, colTypes);
const decoded = decodeTuple(encoded, colOrder, colTypes);
expect(decoded!.name).toBeNull();
expect(decoded!.active).toBeNull();
expect(decoded!.data).toBeNull();
});
it('encodeTuple — undefined 值按 null 处理', () => {
const row = { id: '3', age: 30 } as any;
const encoded = encodeTuple(row, colOrder, colTypes);
const decoded = decodeTuple(encoded, colOrder, colTypes);
expect(decoded!.id).toBe('3');
expect(decoded!.name).toBeNull();
});
it('encodeTuple — date 类型', () => {
const order = ['ts'];
const types = { ts: 'date' };
const row = { ts: '2024-01-15T00:00:00.000Z' };
const encoded = encodeTuple(row, order, types);
const decoded = decodeTuple(encoded, order, types);
expect(decoded!.ts).toBe('2024-01-15T00:00:00.000Z');
});
it('encodeTuple — boolean false', () => {
const order = ['flag'];
const types = { flag: 'boolean' };
const encoded = encodeTuple({ flag: false }, order, types);
const decoded = decodeTuple(encoded, order, types);
expect(decoded!.flag).toBe(false);
});
it('encodeTuple — 负数', () => {
const order = ['val'];
const types = { val: 'number' };
const encoded = encodeTuple({ val: -42.5 }, order, types);
const decoded = decodeTuple(encoded, order, types);
expect(decoded!.val).toBe(-42.5);
});
it('encodeTuple — 空字符串', () => {
const order = ['s'];
const types = { s: 'string' };
const encoded = encodeTuple({ s: '' }, order, types);
const decoded = decodeTuple(encoded, order, types);
expect(decoded!.s).toBe('');
});
it('encodeTuple — 长字符串', () => {
const order = ['s'];
const types = { s: 'string' };
const long = 'x'.repeat(10000);
const encoded = encodeTuple({ s: long }, order, types);
const decoded = decodeTuple(encoded, order, types);
expect(decoded!.s).toBe(long);
});
it('getColumnEncodingMap 返回正确映射', () => {
const map = getColumnEncodingMap(colOrder, colTypes);
expect(map.get('id')).toBe(ColumnEncoding.STRING);
expect(map.get('age')).toBe(ColumnEncoding.NUMBER);
expect(map.get('active')).toBe(ColumnEncoding.BOOLEAN);
expect(map.get('data')).toBe(ColumnEncoding.JSON);
});
it('decodeTuple — 损坏数据返回 null', () => {
const broken = new Uint8Array([0xff, 0xff, 0xff]);
expect(decodeTuple(broken, colOrder, colTypes)).toBeNull();
});
});
// ===================================================================
// Page Format 整合
// ===================================================================
describe('AriaEngine Page — Format', () => {
const colOrder = ['id', 'name'];
const colTypes: Record<string, string> = { id: 'string', name: 'string' };
it('createPage 创建合法页面', () => {
const page = createPage(100, PageType.DATA);
expect(page.pageId).toBe(100);
expect(page.type).toBe(PageType.DATA);
expect(page.pins).toBe(0);
expect(page.dirty).toBe(true);
});
it('pageInsertRow + pageReadRow 往返', () => {
const page = createPage(1, PageType.DATA);
const row = { id: 'u1', name: 'Test' };
const idx = pageInsertRow(page, row, colOrder, colTypes);
expect(idx).toBe(0);
const read = pageReadRow(page, 0, colOrder, colTypes);
expect(read).not.toBeNull();
expect(read!.id).toBe('u1');
expect(read!.name).toBe('Test');
});
it('pageInsertRow — 多次插入', () => {
const page = createPage(1, PageType.DATA);
for (let i = 0; i < 50; i++) {
const idx = pageInsertRow(page, { id: `${i}`, name: `User${i}` }, colOrder, colTypes);
expect(idx).toBe(i);
}
for (let i = 0; i < 50; i++) {
const row = pageReadRow(page, i, colOrder, colTypes);
expect(row!.id).toBe(`${i}`);
}
});
it('pageDeleteRow 标记删除', () => {
const page = createPage(1, PageType.DATA);
pageInsertRow(page, { id: '1', name: 'A' }, colOrder, colTypes);
pageInsertRow(page, { id: '2', name: 'B' }, colOrder, colTypes);
pageDeleteRow(page, 0);
expect(page.dirty).toBe(true);
// 已删除的行读取失败
const row = pageReadRow(page, 0, colOrder, colTypes);
expect(row).toBeNull();
// 未删除的行仍然可读
const row2 = pageReadRow(page, 1, colOrder, colTypes);
expect(row2!.id).toBe('2');
});
it('pageFromBuffer 从 ArrayBuffer 恢复', () => {
const page = createPage(5, PageType.INDEX);
const restored = pageFromBuffer(5, page.data);
expect(restored.pageId).toBe(5);
expect(restored.type).toBe(PageType.INDEX);
expect(restored.dirty).toBe(false);
});
it('computeChecksum + verifyChecksum', () => {
const page = createPage(1, PageType.DATA);
updateChecksum(page);
expect(verifyChecksum(page)).toBe(true);
// 修改页面 → 校验和失效
new Uint8Array(page.data)[100] = 0xff;
expect(verifyChecksum(page)).toBe(false);
});
});
/**
* AriaEngine Page 格式单元测试
* 覆盖: PageHeader / Slot / Tuple 编解码 + PageFormat 整合
*/
import {
PAGE_SIZE, PageType, PAGE_HEADER_SIZE, SLOT_ENTRY_SIZE,
} from '../../src/engine/aria/types';
import {
encodePageHeader, decodePageHeader, initPageHeader, getPageType,
getSlotCount, getFreeStart, setFreeStart, setFreeEnd,
} from '../../src/engine/aria/page/header';
import {
getSlotEntry, setSlotEntry, getSlotDirectorySize,
getFreeSpace, hasEnoughSpace, allocateSlot, readSlotData, freeSlot,
} from '../../src/engine/aria/page/slot';
import {
encodeTuple, decodeTuple, getColumnEncodingMap,
} from '../../src/engine/aria/page/tuple';
import { ColumnEncoding } from '../../src/engine/aria/types';
import {
createPage, pageFromBuffer, pageInsertRow, pageReadRow,
pageDeleteRow, pageUpdateRow, computeChecksum, verifyChecksum, updateChecksum,
} from '../../src/engine/aria/page/format';
// ===================================================================
// PageHeader
// ===================================================================
describe('AriaEngine Page — Header', () => {
let buf: ArrayBuffer;
beforeEach(() => {
buf = new ArrayBuffer(PAGE_SIZE);
});
it('initPageHeader 初始化头部字段', () => {
initPageHeader(buf, 42, PageType.DATA);
const h = decodePageHeader(buf);
expect(h.pageId).toBe(42);
expect(h.type).toBe(PageType.DATA);
expect(h.slotCount).toBe(0);
expect(h.freeStart).toBe(PAGE_HEADER_SIZE);
expect(h.freeEnd).toBe(PAGE_SIZE);
});
it('initPageHeader — INDEX 类型页面', () => {
initPageHeader(buf, 99, PageType.INDEX);
expect(getPageType(buf)).toBe(PageType.INDEX);
});
it('encodePageHeader + decodePageHeader 往返一致', () => {
const header = { pageId: 7, type: PageType.META, freeStart: 32, freeEnd: 4000, slotCount: 5, checksum: 0xdeadbeef };
encodePageHeader(header, buf);
const decoded = decodePageHeader(buf);
expect(decoded.pageId).toBe(7);
expect(decoded.type).toBe(PageType.META);
expect(decoded.freeStart).toBe(32);
expect(decoded.freeEnd).toBe(4000);
expect(decoded.slotCount).toBe(5);
});
it('不同 pageId 正确编解码', () => {
for (const id of [0, 1, 255, 65535, 0xffffffff]) {
initPageHeader(buf, id, PageType.DATA);
expect(decodePageHeader(buf).pageId).toBe(id >>> 0);
}
});
it('setFreeStart / setFreeEnd 修改字段', () => {
initPageHeader(buf, 1, PageType.DATA);
setFreeStart(buf, 100);
setFreeEnd(buf, 3000);
expect(getFreeStart(buf)).toBe(100);
expect(decodePageHeader(buf).freeEnd).toBe(3000);
});
});
// ===================================================================
// Slot Directory
// ===================================================================
describe('AriaEngine Page — Slot', () => {
let buf: ArrayBuffer;
beforeEach(() => {
buf = new ArrayBuffer(PAGE_SIZE);
initPageHeader(buf, 1, PageType.DATA);
});
it('getSlotEntry — 空页面 slotCount 为 0', () => {
expect(getSlotCount(buf)).toBe(0);
});
it('setSlotEntry + getSlotEntry 往返', () => {
// 手动写一个 slot(不通过 allocateSlot
new DataView(buf).setUint16(9, 1, false); // slotCount = 1
setSlotEntry(buf, 0, { offset: 1000, length: 50 });
const entry = getSlotEntry(buf, 0);
expect(entry.offset).toBe(1000);
expect(entry.length).toBe(50);
});
it('getSlotDirectorySize 计算正确', () => {
expect(getSlotDirectorySize(0)).toBe(0);
expect(getSlotDirectorySize(1)).toBe(SLOT_ENTRY_SIZE);
expect(getSlotDirectorySize(10)).toBe(10 * SLOT_ENTRY_SIZE);
});
it('getFreeSpace — 空页面有最大空闲空间', () => {
const free = getFreeSpace(buf);
expect(free).toBe(PAGE_SIZE - PAGE_HEADER_SIZE);
});
it('hasEnoughSpace — 小数据返回 true', () => {
expect(hasEnoughSpace(buf, 100)).toBe(true);
});
it('hasEnoughSpace — 超大数据返回 false', () => {
expect(hasEnoughSpace(buf, PAGE_SIZE * 2)).toBe(false);
});
it('allocateSlot 分配并写入数据', () => {
const data = new Uint8Array([1, 2, 3, 4, 5]);
const idx = allocateSlot(buf, data);
expect(idx).toBe(0);
expect(getSlotCount(buf)).toBe(1);
const readBack = readSlotData(buf, 0);
expect(readBack).not.toBeNull();
expect(Array.from(readBack!)).toEqual([1, 2, 3, 4, 5]);
});
it('allocateSlot 多次分配', () => {
for (let i = 0; i < 10; i++) {
const data = new Uint8Array([i, i + 1]);
const idx = allocateSlot(buf, data);
expect(idx).toBe(i);
}
expect(getSlotCount(buf)).toBe(10);
// 验证每个 slot 数据正确
for (let i = 0; i < 10; i++) {
const data = readSlotData(buf, i);
expect(Array.from(data!)).toEqual([i, i + 1]);
}
});
it('freeSlot 标记为删除', () => {
const data = new Uint8Array([10, 20, 30]);
allocateSlot(buf, data);
freeSlot(buf, 0);
const entry = getSlotEntry(buf, 0);
expect(entry.offset).toBe(0);
expect(entry.length).toBe(0);
});
it('readSlotData — 已删除 slot 返回 null', () => {
allocateSlot(buf, new Uint8Array([1]));
freeSlot(buf, 0);
expect(readSlotData(buf, 0)).toBeNull();
});
it('allocateSlot — 空间不足返回 -1', () => {
// 填满页面
const big = new Uint8Array(PAGE_SIZE - PAGE_HEADER_SIZE - SLOT_ENTRY_SIZE);
const idx1 = allocateSlot(buf, big);
expect(idx1).toBe(0);
const idx2 = allocateSlot(buf, new Uint8Array([1]));
expect(idx2).toBe(-1);
});
});
// ===================================================================
// Tuple Codec
// ===================================================================
describe('AriaEngine Page — Tuple', () => {
const colOrder = ['id', 'name', 'age', 'active', 'data'];
const colTypes: Record<string, string> = {
id: 'string', name: 'string', age: 'number', active: 'boolean', data: 'json',
};
it('encodeTuple + decodeTuple 完整往返', () => {
const row = { id: '1', name: 'Alice', age: 30, active: true, data: { x: 1 } };
const encoded = encodeTuple(row, colOrder, colTypes);
expect(encoded.byteLength).toBeGreaterThan(0);
const decoded = decodeTuple(encoded, colOrder, colTypes);
expect(decoded).not.toBeNull();
expect(decoded!.id).toBe('1');
expect(decoded!.name).toBe('Alice');
expect(decoded!.age).toBe(30);
expect(decoded!.active).toBe(true);
expect(decoded!.data).toEqual({ x: 1 });
});
it('encodeTuple — null 值正确处理', () => {
const row = { id: '2', name: null, age: 25, active: null, data: null };
const encoded = encodeTuple(row, colOrder, colTypes);
const decoded = decodeTuple(encoded, colOrder, colTypes);
expect(decoded!.name).toBeNull();
expect(decoded!.active).toBeNull();
expect(decoded!.data).toBeNull();
});
it('encodeTuple — undefined 值按 null 处理', () => {
const row = { id: '3', age: 30 } as any;
const encoded = encodeTuple(row, colOrder, colTypes);
const decoded = decodeTuple(encoded, colOrder, colTypes);
expect(decoded!.id).toBe('3');
expect(decoded!.name).toBeNull();
});
it('encodeTuple — date 类型', () => {
const order = ['ts'];
const types = { ts: 'date' };
const row = { ts: '2024-01-15T00:00:00.000Z' };
const encoded = encodeTuple(row, order, types);
const decoded = decodeTuple(encoded, order, types);
expect(decoded!.ts).toBe('2024-01-15T00:00:00.000Z');
});
it('encodeTuple — boolean false', () => {
const order = ['flag'];
const types = { flag: 'boolean' };
const encoded = encodeTuple({ flag: false }, order, types);
const decoded = decodeTuple(encoded, order, types);
expect(decoded!.flag).toBe(false);
});
it('encodeTuple — 负数', () => {
const order = ['val'];
const types = { val: 'number' };
const encoded = encodeTuple({ val: -42.5 }, order, types);
const decoded = decodeTuple(encoded, order, types);
expect(decoded!.val).toBe(-42.5);
});
it('encodeTuple — 空字符串', () => {
const order = ['s'];
const types = { s: 'string' };
const encoded = encodeTuple({ s: '' }, order, types);
const decoded = decodeTuple(encoded, order, types);
expect(decoded!.s).toBe('');
});
it('encodeTuple — 长字符串', () => {
const order = ['s'];
const types = { s: 'string' };
const long = 'x'.repeat(10000);
const encoded = encodeTuple({ s: long }, order, types);
const decoded = decodeTuple(encoded, order, types);
expect(decoded!.s).toBe(long);
});
it('getColumnEncodingMap 返回正确映射', () => {
const map = getColumnEncodingMap(colOrder, colTypes);
expect(map.get('id')).toBe(ColumnEncoding.STRING);
expect(map.get('age')).toBe(ColumnEncoding.NUMBER);
expect(map.get('active')).toBe(ColumnEncoding.BOOLEAN);
expect(map.get('data')).toBe(ColumnEncoding.JSON);
});
it('decodeTuple — 损坏数据返回 null', () => {
const broken = new Uint8Array([0xff, 0xff, 0xff]);
expect(decodeTuple(broken, colOrder, colTypes)).toBeNull();
});
});
// ===================================================================
// Page Format 整合
// ===================================================================
describe('AriaEngine Page — Format', () => {
const colOrder = ['id', 'name'];
const colTypes: Record<string, string> = { id: 'string', name: 'string' };
it('createPage 创建合法页面', () => {
const page = createPage(100, PageType.DATA);
expect(page.pageId).toBe(100);
expect(page.type).toBe(PageType.DATA);
expect(page.pins).toBe(0);
expect(page.dirty).toBe(true);
});
it('pageInsertRow + pageReadRow 往返', () => {
const page = createPage(1, PageType.DATA);
const row = { id: 'u1', name: 'Test' };
const idx = pageInsertRow(page, row, colOrder, colTypes);
expect(idx).toBe(0);
const read = pageReadRow(page, 0, colOrder, colTypes);
expect(read).not.toBeNull();
expect(read!.id).toBe('u1');
expect(read!.name).toBe('Test');
});
it('pageInsertRow — 多次插入', () => {
const page = createPage(1, PageType.DATA);
for (let i = 0; i < 50; i++) {
const idx = pageInsertRow(page, { id: `${i}`, name: `User${i}` }, colOrder, colTypes);
expect(idx).toBe(i);
}
for (let i = 0; i < 50; i++) {
const row = pageReadRow(page, i, colOrder, colTypes);
expect(row!.id).toBe(`${i}`);
}
});
it('pageDeleteRow 标记删除', () => {
const page = createPage(1, PageType.DATA);
pageInsertRow(page, { id: '1', name: 'A' }, colOrder, colTypes);
pageInsertRow(page, { id: '2', name: 'B' }, colOrder, colTypes);
pageDeleteRow(page, 0);
expect(page.dirty).toBe(true);
// 已删除的行读取失败
const row = pageReadRow(page, 0, colOrder, colTypes);
expect(row).toBeNull();
// 未删除的行仍然可读
const row2 = pageReadRow(page, 1, colOrder, colTypes);
expect(row2!.id).toBe('2');
});
it('pageFromBuffer 从 ArrayBuffer 恢复', () => {
const page = createPage(5, PageType.INDEX);
const restored = pageFromBuffer(5, page.data);
expect(restored.pageId).toBe(5);
expect(restored.type).toBe(PageType.INDEX);
expect(restored.dirty).toBe(false);
});
it('computeChecksum + verifyChecksum', () => {
const page = createPage(1, PageType.DATA);
updateChecksum(page);
expect(verifyChecksum(page)).toBe(true);
// 修改页面 → 校验和失效
new Uint8Array(page.data)[100] = 0xff;
expect(verifyChecksum(page)).toBe(false);
});
});
+129 -129
View File
@@ -1,129 +1,129 @@
/**
* AriaEngine SSTable Builder + Reader 单元测试
*/
import { SSTableBuilder } from '../../src/engine/aria/index/sstable_builder';
import { SSTableReader } from '../../src/engine/aria/index/sstable';
import type { SSTableMeta } from '../../src/engine/aria/types';
// ===================================================================
// SSTable Builder + Reader
// ===================================================================
describe('AriaEngine — SSTable Builder + Reader', () => {
const makeMeta = (data: Uint8Array): SSTableMeta => ({
id: 1, level: 0, minKey: '', maxKey: '\uffff',
blockCount: 1, totalSize: data.byteLength, bloomData: null,
});
it('构建单条目 SSTable 并精确读取', () => {
const builder = new SSTableBuilder(4096);
builder.add('key1', { name: 'Alice', age: 30 });
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
const result = reader.get('key1');
expect(result).not.toBeNull();
expect(result!.name).toBe('Alice');
expect(result!.age).toBe(30);
});
it('构建多条 SSTable 并全部读取', () => {
const builder = new SSTableBuilder(4096);
const items: [string, Record<string, unknown>][] = [];
for (let i = 0; i < 100; i++) {
const key = `user-${String(i).padStart(3, '0')}`;
const value = { idx: i, name: `User${i}` };
items.push([key, value]);
builder.add(key, value);
}
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
for (const [key, value] of items) {
const result = reader.get(key);
expect(result).not.toBeNull();
expect(result!.idx).toBe(value.idx);
}
});
it('get — 不存在的 key 返回 null', () => {
const builder = new SSTableBuilder(4096);
builder.add('a', { v: 1 });
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
expect(reader.get('nonexistent')).toBeNull();
});
it('rangeScan — 范围查询', () => {
const builder = new SSTableBuilder(4096);
for (let i = 0; i < 20; i++) {
builder.add(`k-${String(i).padStart(2, '0')}`, { v: i });
}
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
const results: [string, Record<string, unknown>][] = [];
reader.rangeScan('k-05', 'k-10', (k, v) => results.push([k, v]));
expect(results).toHaveLength(6);
expect(results[0][0]).toBe('k-05');
expect(results[results.length - 1][0]).toBe('k-10');
});
it('scanAll — 遍历所有条目', () => {
const builder = new SSTableBuilder(4096);
const count = 50;
for (let i = 0; i < count; i++) {
builder.add(`item-${i}`, { idx: i });
}
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
const items: [string, Record<string, unknown>][] = [];
reader.scanAll((k, v) => items.push([k, v]));
expect(items).toHaveLength(count);
});
it('getIndexBlockCount 返回索引块数', () => {
const builder = new SSTableBuilder(256); // 小 block size 触发多个 block
for (let i = 0; i < 100; i++) {
builder.add(`k-${i}`, { data: 'x'.repeat(50) });
}
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
expect(reader.getIndexBlockCount()).toBeGreaterThanOrEqual(1);
});
it('边界 — 空 SSTable 不抛异常', () => {
const builder = new SSTableBuilder(4096);
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
expect(reader.get('any')).toBeNull();
const results: [string, Record<string, unknown>][] = [];
reader.scanAll((k, v) => results.push([k, v]));
expect(results).toHaveLength(0);
});
it('带特殊字符的 key', () => {
const builder = new SSTableBuilder(4096);
// 必须按键排序添加(按 ASCII 排序:空格 < 短横 < 点号)
builder.add('key with space', { v: 3 });
builder.add('key-with-dash', { v: 1 });
builder.add('key.with.dot', { v: 2 });
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
expect(reader.get('key-with-dash')!.v).toBe(1);
expect(reader.get('key.with.dot')!.v).toBe(2);
expect(reader.get('key with space')!.v).toBe(3);
});
it('getEntryCount 返回正确条目数', () => {
const builder = new SSTableBuilder(4096);
builder.add('a', { v: 1 });
builder.add('b', { v: 2 });
builder.add('c', { v: 3 });
expect(builder.getEntryCount()).toBe(3);
});
});
/**
* AriaEngine SSTable Builder + Reader 单元测试
*/
import { SSTableBuilder } from '../../src/engine/aria/index/sstable_builder';
import { SSTableReader } from '../../src/engine/aria/index/sstable';
import type { SSTableMeta } from '../../src/engine/aria/types';
// ===================================================================
// SSTable Builder + Reader
// ===================================================================
describe('AriaEngine — SSTable Builder + Reader', () => {
const makeMeta = (data: Uint8Array): SSTableMeta => ({
id: 1, level: 0, minKey: '', maxKey: '\uffff',
blockCount: 1, totalSize: data.byteLength, bloomData: null,
});
it('构建单条目 SSTable 并精确读取', () => {
const builder = new SSTableBuilder(4096);
builder.add('key1', { name: 'Alice', age: 30 });
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
const result = reader.get('key1');
expect(result).not.toBeNull();
expect(result!.name).toBe('Alice');
expect(result!.age).toBe(30);
});
it('构建多条 SSTable 并全部读取', () => {
const builder = new SSTableBuilder(4096);
const items: [string, Record<string, unknown>][] = [];
for (let i = 0; i < 100; i++) {
const key = `user-${String(i).padStart(3, '0')}`;
const value = { idx: i, name: `User${i}` };
items.push([key, value]);
builder.add(key, value);
}
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
for (const [key, value] of items) {
const result = reader.get(key);
expect(result).not.toBeNull();
expect(result!.idx).toBe(value.idx);
}
});
it('get — 不存在的 key 返回 null', () => {
const builder = new SSTableBuilder(4096);
builder.add('a', { v: 1 });
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
expect(reader.get('nonexistent')).toBeNull();
});
it('rangeScan — 范围查询', () => {
const builder = new SSTableBuilder(4096);
for (let i = 0; i < 20; i++) {
builder.add(`k-${String(i).padStart(2, '0')}`, { v: i });
}
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
const results: [string, Record<string, unknown>][] = [];
reader.rangeScan('k-05', 'k-10', (k, v) => results.push([k, v]));
expect(results).toHaveLength(6);
expect(results[0][0]).toBe('k-05');
expect(results[results.length - 1][0]).toBe('k-10');
});
it('scanAll — 遍历所有条目', () => {
const builder = new SSTableBuilder(4096);
const count = 50;
for (let i = 0; i < count; i++) {
builder.add(`item-${i}`, { idx: i });
}
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
const items: [string, Record<string, unknown>][] = [];
reader.scanAll((k, v) => items.push([k, v]));
expect(items).toHaveLength(count);
});
it('getIndexBlockCount 返回索引块数', () => {
const builder = new SSTableBuilder(256); // 小 block size 触发多个 block
for (let i = 0; i < 100; i++) {
builder.add(`k-${i}`, { data: 'x'.repeat(50) });
}
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
expect(reader.getIndexBlockCount()).toBeGreaterThanOrEqual(1);
});
it('边界 — 空 SSTable 不抛异常', () => {
const builder = new SSTableBuilder(4096);
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
expect(reader.get('any')).toBeNull();
const results: [string, Record<string, unknown>][] = [];
reader.scanAll((k, v) => results.push([k, v]));
expect(results).toHaveLength(0);
});
it('带特殊字符的 key', () => {
const builder = new SSTableBuilder(4096);
// 必须按键排序添加(按 ASCII 排序:空格 < 短横 < 点号)
builder.add('key with space', { v: 3 });
builder.add('key-with-dash', { v: 1 });
builder.add('key.with.dot', { v: 2 });
const { sstableData } = builder.build();
const reader = new SSTableReader(sstableData, makeMeta(sstableData));
expect(reader.get('key-with-dash')!.v).toBe(1);
expect(reader.get('key.with.dot')!.v).toBe(2);
expect(reader.get('key with space')!.v).toBe(3);
});
it('getEntryCount 返回正确条目数', () => {
const builder = new SSTableBuilder(4096);
builder.add('a', { v: 1 });
builder.add('b', { v: 2 });
builder.add('c', { v: 3 });
expect(builder.getEntryCount()).toBe(3);
});
});
+289 -289
View File
@@ -1,289 +1,289 @@
/**
* AriaEngine WAL + MVCC 单元测试
*/
import { WAL, type WALStore } from '../../src/engine/aria/wal/log';
import { WALRecordType, type WALRecord } from '../../src/engine/aria/types';
import { CheckpointManager, type Flushable } from '../../src/engine/aria/wal/checkpoint';
import { MVCCManager } from '../../src/engine/aria/transaction/mvcc';
// ===================================================================
// WAL 存储 Mock
// ===================================================================
class MockWALStore implements WALStore {
chunks: Uint8Array[] = [];
async append(data: Uint8Array) { this.chunks.push(data); }
async readAll(): Promise<Uint8Array> {
const total = this.chunks.reduce((s, c) => s + c.byteLength, 0);
const combined = new Uint8Array(total);
let off = 0;
for (const c of this.chunks) { combined.set(c, off); off += c.byteLength; }
return combined;
}
async truncate() { this.chunks = []; }
async exists() { return this.chunks.length > 0; }
}
// ===================================================================
// WAL 测试
// ===================================================================
describe('AriaEngine — WAL', () => {
it('append 记录后可恢复', async () => {
const store = new MockWALStore();
const wal = new WAL(store, true, 'full');
wal.append({ type: WALRecordType.INSERT, txnId: 1, tableName: 'users', key: '1', data: { name: 'Alice' } });
wal.append({ type: WALRecordType.INSERT, txnId: 1, tableName: 'users', key: '2', data: { name: 'Bob' } });
const records: WALRecord[] = [];
await wal.recover((r) => records.push(r));
expect(records).toHaveLength(2);
expect(records[0].tableName).toBe('users');
expect(records[0].key).toBe('1');
});
it('batch 模式缓冲后 flush', async () => {
const store = new MockWALStore();
const wal = new WAL(store, true, 'batch');
wal.append({ type: WALRecordType.UPDATE, txnId: 2, tableName: 'items', key: 'a', data: { v: 1 } });
wal.append({ type: WALRecordType.DELETE, txnId: 2, tableName: 'items', key: 'b' });
// 未 flush 前无法恢复
let records: WALRecord[] = [];
await wal.recover((r) => records.push(r));
expect(records).toHaveLength(0);
await wal.flush();
records = [];
await wal.recover((r) => records.push(r));
expect(records).toHaveLength(2);
});
it('none 模式不记录', async () => {
const store = new MockWALStore();
const wal = new WAL(store, false, 'none');
wal.append({ type: WALRecordType.INSERT, txnId: 3, tableName: 'x', key: 'y', data: {} });
expect(store.chunks).toHaveLength(0);
});
it('checkpoint 清空 WAL', async () => {
const store = new MockWALStore();
const wal = new WAL(store, true, 'full');
wal.append({ type: WALRecordType.CREATE_TABLE, txnId: 0, tableName: 't', key: '' });
expect(await store.exists()).toBe(true);
await wal.checkpoint();
expect(await store.exists()).toBe(false);
});
it('getLSN 跟踪序列号', () => {
const store = new MockWALStore();
const wal = new WAL(store, true, 'full');
expect(wal.getLSN()).toBe(0);
wal.append({ type: WALRecordType.BEGIN, txnId: 10, tableName: '', key: '' });
expect(wal.getLSN()).toBe(1);
wal.append({ type: WALRecordType.COMMIT, txnId: 10, tableName: '', key: '' });
expect(wal.getLSN()).toBe(2);
});
it('isEnabled 反映配置', () => {
expect(new WAL(new MockWALStore(), true).isEnabled()).toBe(true);
expect(new WAL(new MockWALStore(), false).isEnabled()).toBe(false);
});
it('多种记录类型编解码', async () => {
const store = new MockWALStore();
const wal = new WAL(store, true, 'full');
wal.append({ type: WALRecordType.BEGIN, txnId: 100, tableName: '', key: '' });
wal.append({ type: WALRecordType.INSERT, txnId: 100, tableName: 'users', key: '1', data: { x: 'hello' } });
wal.append({ type: WALRecordType.UPDATE, txnId: 100, tableName: 'users', key: '1', data: { x: 'world' } });
wal.append({ type: WALRecordType.COMMIT, txnId: 100, tableName: '', key: '' });
const records: WALRecord[] = [];
await wal.recover((r) => records.push(r));
expect(records).toHaveLength(4);
expect(records[0].type).toBe(WALRecordType.BEGIN);
expect(records[1].type).toBe(WALRecordType.INSERT);
expect(records[2].type).toBe(WALRecordType.UPDATE);
expect(records[3].type).toBe(WALRecordType.COMMIT);
});
it('getBufferedCount 返回缓冲数', () => {
const wal = new WAL(new MockWALStore(), true, 'batch');
expect(wal.getBufferedCount()).toBe(0);
wal.append({ type: WALRecordType.INSERT, txnId: 0, tableName: 't', key: 'k' });
expect(wal.getBufferedCount()).toBe(1);
});
});
// ===================================================================
// Checkpoint 测试(使用安全 Mock,避免 null 引用导致 CI 卡死)
// ===================================================================
describe('AriaEngine — CheckpointManager', () => {
class MockFlushable implements Flushable { flushed = false; async flushAll() { this.flushed = true; } }
class MockLSM { flushed = false; async flush() { this.flushed = true; } }
class MockWAL { checkpointed = false; async checkpoint() { this.checkpointed = true; } async flush() {} }
it('tick 未达间隔不触发 checkpoint', async () => {
const lsm = new MockLSM();
const wal = new MockWAL();
const flushable = new MockFlushable();
const cm = new CheckpointManager(lsm as any, wal as any, flushable, 100);
await cm.tick();
await cm.tick();
expect(cm.getOpCount()).toBe(2);
expect(flushable.flushed).toBe(false);
expect(lsm.flushed).toBe(false);
});
it('setInterval 修改间隔后 tick 触发 checkpoint', async () => {
const lsm = new MockLSM();
const wal = new MockWAL();
const cm = new CheckpointManager(lsm as any, wal as any, null, 1000);
cm.setInterval(2);
await cm.tick();
await cm.tick();
expect(cm.getOpCount()).toBe(0); // reset after checkpoint
expect(lsm.flushed).toBe(true);
expect(wal.checkpointed).toBe(true);
});
it('forceCheckpoint 强制触发', async () => {
const lsm = new MockLSM();
const wal = new MockWAL();
const cm = new CheckpointManager(lsm as any, wal as any, null, 100);
await cm.forceCheckpoint();
expect(cm.getOpCount()).toBe(0);
expect(lsm.flushed).toBe(true);
expect(wal.checkpointed).toBe(true);
});
});
// ===================================================================
// MVCC 测试
// ===================================================================
describe('AriaEngine — MVCC', () => {
let mvcc: MVCCManager;
beforeEach(() => { mvcc = new MVCCManager(); });
it('beginTransaction 分配唯一 ID', () => {
const id1 = mvcc.beginTransaction();
const id2 = mvcc.beginTransaction();
expect(id1).not.toBe(id2);
expect(mvcc.isActive(id1)).toBe(true);
expect(mvcc.isActive(id2)).toBe(true);
});
it('commit 后 isActive 返回 false', () => {
const txnId = mvcc.beginTransaction();
mvcc.commitTransaction(txnId);
expect(mvcc.isActive(txnId)).toBe(false);
});
it('rollback 后 isActive 返回 false', () => {
const txnId = mvcc.beginTransaction();
mvcc.rollbackTransaction(txnId);
expect(mvcc.isActive(txnId)).toBe(false);
});
it('writeVersion + readVersion 往返', () => {
const txnId = mvcc.beginTransaction();
mvcc.writeVersion('users', '1', { name: 'Alice', age: 30 }, txnId);
const val = mvcc.readVersion('users', '1', txnId);
expect(val).not.toBeNull();
expect(val!.name).toBe('Alice');
});
it('未提交版本对其他事务不可见', () => {
const txn1 = mvcc.beginTransaction();
mvcc.writeVersion('users', '1', { name: 'Alice' }, txn1);
const txn2 = mvcc.beginTransaction();
const val = mvcc.readVersion('users', '1', txn2);
expect(val).toBeNull(); // txn1's write not yet committed
});
it('commit 后新事务可见', () => {
const txn1 = mvcc.beginTransaction();
mvcc.writeVersion('users', '1', { name: 'Alice' }, txn1);
mvcc.commitTransaction(txn1);
const txn2 = mvcc.beginTransaction();
const val = mvcc.readVersion('users', '1', txn2);
expect(val).not.toBeNull();
expect(val!.name).toBe('Alice');
});
it('rollback 移除写入的版本', () => {
const txnId = mvcc.beginTransaction();
mvcc.writeVersion('users', '1', { name: 'Temp' }, txnId);
mvcc.rollbackTransaction(txnId);
const txn2 = mvcc.beginTransaction();
expect(mvcc.readVersion('users', '1', txn2)).toBeNull();
});
it('deleteVersion 创建墓碑', () => {
const txnId = mvcc.beginTransaction();
mvcc.writeVersion('users', '1', { name: 'Alice' }, txnId);
mvcc.commitTransaction(txnId);
// delete
const txn2 = mvcc.beginTransaction();
mvcc.deleteVersion('users', '1', txn2);
mvcc.commitTransaction(txn2);
// 删除后读取
const txn3 = mvcc.beginTransaction();
const val = mvcc.readVersion('users', '1', txn3);
expect(val).not.toBeNull();
expect((val! as any).__mvcc_tombstone).toBe(true);
});
it('getLatestCommittedVersions 返回最新已提交', () => {
const txnId = mvcc.beginTransaction();
mvcc.writeVersion('users', '1', { name: 'Alice' }, txnId);
mvcc.writeVersion('users', '2', { name: 'Bob' }, txnId);
mvcc.commitTransaction(txnId);
const result = mvcc.getLatestCommittedVersions('users');
expect(result['1'].name).toBe('Alice');
expect(result['2'].name).toBe('Bob');
});
it('clearTable 清理指定表', () => {
const txnId = mvcc.beginTransaction();
mvcc.writeVersion('users', '1', { name: 'A' }, txnId);
mvcc.writeVersion('users', '2', { name: 'B' }, txnId);
mvcc.writeVersion('orders', 'o1', { amt: 100 }, txnId);
mvcc.commitTransaction(txnId);
mvcc.clearTable('users');
const txn2 = mvcc.beginTransaction();
expect(mvcc.readVersion('users', '1', txn2)).toBeNull();
expect(mvcc.readVersion('users', '2', txn2)).toBeNull();
expect(mvcc.readVersion('orders', 'o1', txn2)).not.toBeNull();
});
it('getActiveTxnCount 返回活跃事务数', () => {
expect(mvcc.getActiveTxnCount()).toBe(0);
mvcc.beginTransaction();
mvcc.beginTransaction();
expect(mvcc.getActiveTxnCount()).toBe(2);
});
it('gc 清理过旧版本', () => {
// 创建很多版本后 gc
for (let i = 0; i < 200; i++) {
const txnId = mvcc.beginTransaction();
mvcc.writeVersion('users', '1', { ver: i }, txnId);
mvcc.commitTransaction(txnId);
}
mvcc.gc(100);
// gc 后应可继续操作
const txnId = mvcc.beginTransaction();
const val = mvcc.readVersion('users', '1', txnId);
expect(val).not.toBeNull();
});
});
/**
* AriaEngine WAL + MVCC 单元测试
*/
import { WAL, type WALStore } from '../../src/engine/aria/wal/log';
import { WALRecordType, type WALRecord } from '../../src/engine/aria/types';
import { CheckpointManager, type Flushable } from '../../src/engine/aria/wal/checkpoint';
import { MVCCManager } from '../../src/engine/aria/transaction/mvcc';
// ===================================================================
// WAL 存储 Mock
// ===================================================================
class MockWALStore implements WALStore {
chunks: Uint8Array[] = [];
async append(data: Uint8Array) { this.chunks.push(data); }
async readAll(): Promise<Uint8Array> {
const total = this.chunks.reduce((s, c) => s + c.byteLength, 0);
const combined = new Uint8Array(total);
let off = 0;
for (const c of this.chunks) { combined.set(c, off); off += c.byteLength; }
return combined;
}
async truncate() { this.chunks = []; }
async exists() { return this.chunks.length > 0; }
}
// ===================================================================
// WAL 测试
// ===================================================================
describe('AriaEngine — WAL', () => {
it('append 记录后可恢复', async () => {
const store = new MockWALStore();
const wal = new WAL(store, true, 'full');
wal.append({ type: WALRecordType.INSERT, txnId: 1, tableName: 'users', key: '1', data: { name: 'Alice' } });
wal.append({ type: WALRecordType.INSERT, txnId: 1, tableName: 'users', key: '2', data: { name: 'Bob' } });
const records: WALRecord[] = [];
await wal.recover((r) => records.push(r));
expect(records).toHaveLength(2);
expect(records[0].tableName).toBe('users');
expect(records[0].key).toBe('1');
});
it('batch 模式缓冲后 flush', async () => {
const store = new MockWALStore();
const wal = new WAL(store, true, 'batch');
wal.append({ type: WALRecordType.UPDATE, txnId: 2, tableName: 'items', key: 'a', data: { v: 1 } });
wal.append({ type: WALRecordType.DELETE, txnId: 2, tableName: 'items', key: 'b' });
// 未 flush 前无法恢复
let records: WALRecord[] = [];
await wal.recover((r) => records.push(r));
expect(records).toHaveLength(0);
await wal.flush();
records = [];
await wal.recover((r) => records.push(r));
expect(records).toHaveLength(2);
});
it('none 模式不记录', async () => {
const store = new MockWALStore();
const wal = new WAL(store, false, 'none');
wal.append({ type: WALRecordType.INSERT, txnId: 3, tableName: 'x', key: 'y', data: {} });
expect(store.chunks).toHaveLength(0);
});
it('checkpoint 清空 WAL', async () => {
const store = new MockWALStore();
const wal = new WAL(store, true, 'full');
wal.append({ type: WALRecordType.CREATE_TABLE, txnId: 0, tableName: 't', key: '' });
expect(await store.exists()).toBe(true);
await wal.checkpoint();
expect(await store.exists()).toBe(false);
});
it('getLSN 跟踪序列号', () => {
const store = new MockWALStore();
const wal = new WAL(store, true, 'full');
expect(wal.getLSN()).toBe(0);
wal.append({ type: WALRecordType.BEGIN, txnId: 10, tableName: '', key: '' });
expect(wal.getLSN()).toBe(1);
wal.append({ type: WALRecordType.COMMIT, txnId: 10, tableName: '', key: '' });
expect(wal.getLSN()).toBe(2);
});
it('isEnabled 反映配置', () => {
expect(new WAL(new MockWALStore(), true).isEnabled()).toBe(true);
expect(new WAL(new MockWALStore(), false).isEnabled()).toBe(false);
});
it('多种记录类型编解码', async () => {
const store = new MockWALStore();
const wal = new WAL(store, true, 'full');
wal.append({ type: WALRecordType.BEGIN, txnId: 100, tableName: '', key: '' });
wal.append({ type: WALRecordType.INSERT, txnId: 100, tableName: 'users', key: '1', data: { x: 'hello' } });
wal.append({ type: WALRecordType.UPDATE, txnId: 100, tableName: 'users', key: '1', data: { x: 'world' } });
wal.append({ type: WALRecordType.COMMIT, txnId: 100, tableName: '', key: '' });
const records: WALRecord[] = [];
await wal.recover((r) => records.push(r));
expect(records).toHaveLength(4);
expect(records[0].type).toBe(WALRecordType.BEGIN);
expect(records[1].type).toBe(WALRecordType.INSERT);
expect(records[2].type).toBe(WALRecordType.UPDATE);
expect(records[3].type).toBe(WALRecordType.COMMIT);
});
it('getBufferedCount 返回缓冲数', () => {
const wal = new WAL(new MockWALStore(), true, 'batch');
expect(wal.getBufferedCount()).toBe(0);
wal.append({ type: WALRecordType.INSERT, txnId: 0, tableName: 't', key: 'k' });
expect(wal.getBufferedCount()).toBe(1);
});
});
// ===================================================================
// Checkpoint 测试(使用安全 Mock,避免 null 引用导致 CI 卡死)
// ===================================================================
describe('AriaEngine — CheckpointManager', () => {
class MockFlushable implements Flushable { flushed = false; async flushAll() { this.flushed = true; } }
class MockLSM { flushed = false; async flush() { this.flushed = true; } }
class MockWAL { checkpointed = false; async checkpoint() { this.checkpointed = true; } async flush() {} }
it('tick 未达间隔不触发 checkpoint', async () => {
const lsm = new MockLSM();
const wal = new MockWAL();
const flushable = new MockFlushable();
const cm = new CheckpointManager(lsm as any, wal as any, flushable, 100);
await cm.tick();
await cm.tick();
expect(cm.getOpCount()).toBe(2);
expect(flushable.flushed).toBe(false);
expect(lsm.flushed).toBe(false);
});
it('setInterval 修改间隔后 tick 触发 checkpoint', async () => {
const lsm = new MockLSM();
const wal = new MockWAL();
const cm = new CheckpointManager(lsm as any, wal as any, null, 1000);
cm.setInterval(2);
await cm.tick();
await cm.tick();
expect(cm.getOpCount()).toBe(0); // reset after checkpoint
expect(lsm.flushed).toBe(true);
expect(wal.checkpointed).toBe(true);
});
it('forceCheckpoint 强制触发', async () => {
const lsm = new MockLSM();
const wal = new MockWAL();
const cm = new CheckpointManager(lsm as any, wal as any, null, 100);
await cm.forceCheckpoint();
expect(cm.getOpCount()).toBe(0);
expect(lsm.flushed).toBe(true);
expect(wal.checkpointed).toBe(true);
});
});
// ===================================================================
// MVCC 测试
// ===================================================================
describe('AriaEngine — MVCC', () => {
let mvcc: MVCCManager;
beforeEach(() => { mvcc = new MVCCManager(); });
it('beginTransaction 分配唯一 ID', () => {
const id1 = mvcc.beginTransaction();
const id2 = mvcc.beginTransaction();
expect(id1).not.toBe(id2);
expect(mvcc.isActive(id1)).toBe(true);
expect(mvcc.isActive(id2)).toBe(true);
});
it('commit 后 isActive 返回 false', () => {
const txnId = mvcc.beginTransaction();
mvcc.commitTransaction(txnId);
expect(mvcc.isActive(txnId)).toBe(false);
});
it('rollback 后 isActive 返回 false', () => {
const txnId = mvcc.beginTransaction();
mvcc.rollbackTransaction(txnId);
expect(mvcc.isActive(txnId)).toBe(false);
});
it('writeVersion + readVersion 往返', () => {
const txnId = mvcc.beginTransaction();
mvcc.writeVersion('users', '1', { name: 'Alice', age: 30 }, txnId);
const val = mvcc.readVersion('users', '1', txnId);
expect(val).not.toBeNull();
expect(val!.name).toBe('Alice');
});
it('未提交版本对其他事务不可见', () => {
const txn1 = mvcc.beginTransaction();
mvcc.writeVersion('users', '1', { name: 'Alice' }, txn1);
const txn2 = mvcc.beginTransaction();
const val = mvcc.readVersion('users', '1', txn2);
expect(val).toBeNull(); // txn1's write not yet committed
});
it('commit 后新事务可见', () => {
const txn1 = mvcc.beginTransaction();
mvcc.writeVersion('users', '1', { name: 'Alice' }, txn1);
mvcc.commitTransaction(txn1);
const txn2 = mvcc.beginTransaction();
const val = mvcc.readVersion('users', '1', txn2);
expect(val).not.toBeNull();
expect(val!.name).toBe('Alice');
});
it('rollback 移除写入的版本', () => {
const txnId = mvcc.beginTransaction();
mvcc.writeVersion('users', '1', { name: 'Temp' }, txnId);
mvcc.rollbackTransaction(txnId);
const txn2 = mvcc.beginTransaction();
expect(mvcc.readVersion('users', '1', txn2)).toBeNull();
});
it('deleteVersion 创建墓碑', () => {
const txnId = mvcc.beginTransaction();
mvcc.writeVersion('users', '1', { name: 'Alice' }, txnId);
mvcc.commitTransaction(txnId);
// delete
const txn2 = mvcc.beginTransaction();
mvcc.deleteVersion('users', '1', txn2);
mvcc.commitTransaction(txn2);
// 删除后读取
const txn3 = mvcc.beginTransaction();
const val = mvcc.readVersion('users', '1', txn3);
expect(val).not.toBeNull();
expect((val! as any).__mvcc_tombstone).toBe(true);
});
it('getLatestCommittedVersions 返回最新已提交', () => {
const txnId = mvcc.beginTransaction();
mvcc.writeVersion('users', '1', { name: 'Alice' }, txnId);
mvcc.writeVersion('users', '2', { name: 'Bob' }, txnId);
mvcc.commitTransaction(txnId);
const result = mvcc.getLatestCommittedVersions('users');
expect(result['1'].name).toBe('Alice');
expect(result['2'].name).toBe('Bob');
});
it('clearTable 清理指定表', () => {
const txnId = mvcc.beginTransaction();
mvcc.writeVersion('users', '1', { name: 'A' }, txnId);
mvcc.writeVersion('users', '2', { name: 'B' }, txnId);
mvcc.writeVersion('orders', 'o1', { amt: 100 }, txnId);
mvcc.commitTransaction(txnId);
mvcc.clearTable('users');
const txn2 = mvcc.beginTransaction();
expect(mvcc.readVersion('users', '1', txn2)).toBeNull();
expect(mvcc.readVersion('users', '2', txn2)).toBeNull();
expect(mvcc.readVersion('orders', 'o1', txn2)).not.toBeNull();
});
it('getActiveTxnCount 返回活跃事务数', () => {
expect(mvcc.getActiveTxnCount()).toBe(0);
mvcc.beginTransaction();
mvcc.beginTransaction();
expect(mvcc.getActiveTxnCount()).toBe(2);
});
it('gc 清理过旧版本', () => {
// 创建很多版本后 gc
for (let i = 0; i < 200; i++) {
const txnId = mvcc.beginTransaction();
mvcc.writeVersion('users', '1', { ver: i }, txnId);
mvcc.commitTransaction(txnId);
}
mvcc.gc(100);
// gc 后应可继续操作
const txnId = mvcc.beginTransaction();
const val = mvcc.readVersion('users', '1', txnId);
expect(val).not.toBeNull();
});
});
+932 -932
View File
File diff suppressed because it is too large Load Diff
+200
View File
@@ -0,0 +1,200 @@
/**
* React 集成 hooks 测试(v0.2.6 补强)
* @module tests/integrations/react
*
* 项目零运行时依赖(react 为 peer dependency),
* 使用最小 React mock 验证 hooks 的真实逻辑:
* - mount 时执行查询并更新状态
* - 错误路径
* - refresh 重新执行
* - 表名校验(SQL 注入防护)
*/
import { MetonaSqlark } from '../../src/core';
// ---- 最小 React mock(自包含:jest.mock 工厂不能引用外部变量) ----
jest.mock('react', () => {
const stateStore: any[] = [];
const registeredEffects = new Set<number>();
const effectQueue: Array<() => void | Promise<void>> = [];
let cursor = 0;
return {
useState: (init: any) => {
const idx = cursor++;
if (!(idx in stateStore)) stateStore[idx] = init;
return [
stateStore[idx],
(v: any) => {
stateStore[idx] = typeof v === 'function' ? v(stateStore[idx]) : v;
},
];
},
// 按 hook 调用位置去重:同一位置的 effect 只在首次 render 注册
useEffect: (fn: any, _deps: any[]) => {
const idx = cursor;
if (!registeredEffects.has(idx)) {
registeredEffects.add(idx);
effectQueue.push(fn);
}
},
useCallback: (fn: any) => fn,
useRef: (init: any) => ({ current: init }),
/** 模拟一次组件渲染:cursor 归零后执行 hook 函数 */
__mockRender: (fn: () => any): any => {
cursor = 0;
return fn();
},
__mockEffects: effectQueue,
__mockReset: () => {
stateStore.length = 0;
cursor = 0;
effectQueue.length = 0;
registeredEffects.clear();
},
};
}, { virtual: true });
import { useQuery, useTable, useDatabase } from '../../src/integrations/react';
/** mock 模块内的 effect 队列与渲染控制(自包含作用域) */
const reactMock = jest.requireMock('react') as {
__mockEffects: Array<() => void | Promise<void>>;
__mockRender: (fn: () => any) => any;
__mockReset: () => void;
};
beforeEach(() => {
reactMock.__mockReset();
});
/** 模拟组件挂载:执行 useEffect 中注册的回调 */
async function flushEffects(): Promise<void> {
const fns = reactMock.__mockEffects.splice(0);
for (const fn of fns) {
await fn();
}
}
/** 模拟组件渲染(cursor 归零,读取最新状态) */
function render<T>(fn: () => T): T {
return reactMock.__mockRender(fn);
}
describe('useQuery', () => {
test('mount 时执行 SQL 查询并更新 data/loading', async () => {
const db = { query: jest.fn().mockResolvedValue([{ id: '1', name: 'Alice' }]) } as any;
const mount = () => useQuery(db, 'SELECT * FROM users');
const hook = render(mount);
expect(hook.loading).toBe(true);
expect(db.query).not.toHaveBeenCalled();
await flushEffects();
const after = render(mount); // 模拟重渲染读取最新状态
expect(db.query).toHaveBeenCalledWith('SELECT * FROM users');
expect(after.loading).toBe(false);
expect(after.data).toEqual([{ id: '1', name: 'Alice' }]);
expect(after.error).toBeNull();
});
test('查询失败时设置 error 且 data 保持空', async () => {
const db = { query: jest.fn().mockRejectedValue(new Error('query boom')) } as any;
const mount = () => useQuery(db, 'SELECT * FROM users');
render(mount);
await flushEffects();
const after = render(mount);
expect(after.error).toBeInstanceOf(Error);
expect((after.error as Error).message).toBe('query boom');
expect(after.data).toEqual([]);
expect(after.loading).toBe(false);
});
test('refresh 可重新执行查询', async () => {
let call = 0;
const db = { query: jest.fn().mockImplementation(async () => [{ n: ++call }]) } as any;
const mount = () => useQuery(db, 'SELECT * FROM users');
const hook = render(mount);
await flushEffects();
expect(render(mount).data).toEqual([{ n: 1 }]);
await hook.refresh();
await flushEffects();
expect(db.query).toHaveBeenCalledTimes(2);
expect(render(mount).data).toEqual([{ n: 2 }]);
});
test('不同 SQL 使用各自独立的 hook 状态', async () => {
const db = { query: jest.fn().mockResolvedValue([]) } as any;
const hook1 = useQuery(db, 'SELECT * FROM a');
const hook2 = useQuery(db, 'SELECT * FROM b');
await flushEffects();
expect(hook1).not.toBe(hook2);
expect(db.query).toHaveBeenCalledWith('SELECT * FROM a');
expect(db.query).toHaveBeenCalledWith('SELECT * FROM b');
});
});
describe('useTable', () => {
test('合法表名执行全表查询', async () => {
const db = { query: jest.fn().mockResolvedValue([{ id: 1 }]) } as any;
const mount = () => useTable(db, 'users');
render(mount);
await flushEffects();
const after = render(mount);
expect(db.query).toHaveBeenCalledWith('SELECT * FROM users');
expect(after.data).toEqual([{ id: 1 }]);
expect(after.loading).toBe(false);
});
test('非法表名抛出校验错误(SQL 注入防护)', () => {
const db = { query: jest.fn() } as any;
expect(() => useTable(db, 'users; DROP TABLE orders')).toThrow(/Invalid table name/);
expect(() => useTable(db, "users' OR '1'='1")).toThrow(/Invalid table name/);
expect(() => useTable(db, '1users')).toThrow(/Invalid table name/);
expect(db.query).not.toHaveBeenCalled();
});
});
describe('useDatabase', () => {
test('创建数据库实例并初始化', async () => {
const initSpy = jest.spyOn(MetonaSqlark.prototype, 'init').mockResolvedValue();
const closeSpy = jest.spyOn(MetonaSqlark.prototype, 'close').mockResolvedValue();
const mount = () => useDatabase({ name: 'hook-test' });
const hook = render(mount);
expect(hook.ready).toBe(false);
await flushEffects();
const after = render(mount);
expect(initSpy).toHaveBeenCalled();
expect(after.db).toBeInstanceOf(MetonaSqlark);
expect(after.ready).toBe(true);
expect(after.error).toBeNull();
initSpy.mockRestore();
closeSpy.mockRestore();
});
test('初始化失败时设置 error', async () => {
const initSpy = jest.spyOn(MetonaSqlark.prototype, 'init').mockRejectedValue(new Error('init fail'));
const mount = () => useDatabase({ name: 'hook-fail' });
render(mount);
await flushEffects();
const after = render(mount);
expect(after.db).toBeNull();
expect(after.ready).toBe(false);
expect(after.error).toBeInstanceOf(Error);
expect((after.error as Error).message).toBe('init fail');
initSpy.mockRestore();
});
});
+167
View File
@@ -0,0 +1,167 @@
/**
* Vue 集成 composables 测试(v0.2.6 补强)
* @module tests/integrations/vue
*
* 项目零运行时依赖(vue 为 peer dependency),
* 使用最小 Vue mock 验证 composables 的真实逻辑:
* - onMounted 时执行查询
* - watch sql/deps 变化重新执行
* - refresh 手动刷新
* - 表名校验(SQL 注入防护)
*/
import 'fake-indexeddb/auto';
// ---- 最小 Vue mock(自包含:jest.mock 工厂不能引用外部变量) ----
jest.mock('vue', () => {
const mountQueue: Array<() => void | Promise<void>> = [];
const watchList: Array<{ sources: any[]; cb: () => void | Promise<void> }> = [];
return {
ref: (init: any) => {
const box: { value: any } = { value: init };
return box;
},
watch: (sources: any[], cb: any) => {
watchList.push({ sources, cb });
},
onMounted: (fn: any) => {
mountQueue.push(fn);
},
__mockMounted: mountQueue,
__mockWatch: watchList,
__mockReset: () => {
mountQueue.length = 0;
watchList.length = 0;
},
};
}, { virtual: true });
import { useSqlarkQuery, useSqlarkTable, useSqlarkDatabase } from '../../src/integrations/vue';
/** mock 模块内的挂载/监听队列(自包含作用域) */
const vueMock = jest.requireMock('vue') as {
__mockMounted: Array<() => void | Promise<void>>;
__mockWatch: Array<{ sources: any[]; cb: () => void | Promise<void> }>;
__mockReset: () => void;
};
beforeEach(() => {
vueMock.__mockReset();
});
/** 模拟组件挂载:执行 onMounted 注册的回调 */
async function flushMounted(): Promise<void> {
const fns = vueMock.__mockMounted.splice(0);
for (const fn of fns) {
await fn();
}
}
/** 触发 watch 回调 */
async function flushWatch(): Promise<void> {
const pairs = vueMock.__mockWatch.splice(0);
for (const { cb } of pairs) {
await cb();
}
}
/** 构造最小响应式引用(与 vue.mock 的 ref 等价) */
function makeRef<T>(init: T): { value: T } {
return { value: init };
}
describe('useSqlarkQuery', () => {
test('onMounted 时执行 SQL 查询并更新响应式 data', async () => {
const db = { query: jest.fn().mockResolvedValue([{ id: '1' }]) } as any;
const hook = useSqlarkQuery(db, 'SELECT * FROM users');
expect(hook.loading.value).toBe(true);
expect(db.query).not.toHaveBeenCalled();
await flushMounted();
expect(db.query).toHaveBeenCalledWith('SELECT * FROM users');
expect(hook.loading.value).toBe(false);
expect(hook.data.value).toEqual([{ id: '1' }]);
expect(hook.error.value).toBeNull();
});
test('查询失败时设置 error', async () => {
const db = { query: jest.fn().mockRejectedValue(new Error('vue boom')) } as any;
const hook = useSqlarkQuery(db, 'SELECT * FROM users');
await flushMounted();
expect(hook.error.value).toBeInstanceOf(Error);
expect((hook.error.value as Error).message).toBe('vue boom');
expect(hook.data.value).toEqual([]);
});
test('refresh 重新执行查询', async () => {
let call = 0;
const db = { query: jest.fn().mockImplementation(async () => [{ n: ++call }]) } as any;
const hook = useSqlarkQuery(db, 'SELECT * FROM users');
await flushMounted();
expect(hook.data.value).toEqual([{ n: 1 }]);
await hook.refresh();
expect(db.query).toHaveBeenCalledTimes(2);
expect(hook.data.value).toEqual([{ n: 2 }]);
});
test('watch 注册在 sql 与 deps 上', async () => {
const db = { query: jest.fn().mockResolvedValue([]) } as any;
const sqlRef = makeRef('SELECT * FROM users');
useSqlarkQuery(db, sqlRef.value, [sqlRef]);
expect(vueMock.__mockWatch).toHaveLength(1);
expect(vueMock.__mockWatch[0].sources).toHaveLength(2); // [() => sql, ...deps]
// 模拟依赖变化触发 watch
sqlRef.value = 'SELECT * FROM orders';
await flushWatch();
expect(db.query).toHaveBeenCalledWith('SELECT * FROM users');
});
});
describe('useSqlarkTable', () => {
test('合法表名执行全表查询', async () => {
const db = { query: jest.fn().mockResolvedValue([{ id: 1 }]) } as any;
const hook = useSqlarkTable(db, 'users');
await flushMounted();
expect(db.query).toHaveBeenCalledWith('SELECT * FROM users');
expect(hook.data.value).toEqual([{ id: 1 }]);
});
test('非法表名抛出校验错误(SQL 注入防护)', () => {
const db = { query: jest.fn() } as any;
expect(() => useSqlarkTable(db, 'users; DELETE FROM orders')).toThrow(/Invalid table name/);
expect(() => useSqlarkTable(db, 'users--')).toThrow(/Invalid table name/);
expect(db.query).not.toHaveBeenCalled();
});
});
describe('useSqlarkDatabase', () => {
test('onMounted 创建并初始化数据库实例', async () => {
const hook = useSqlarkDatabase({ name: 'vue-hook' });
expect(hook.ready.value).toBe(false);
await flushMounted();
expect(hook.db.value).not.toBeNull();
expect(hook.ready.value).toBe(true);
expect(hook.error.value).toBeNull();
});
test('初始化失败时设置 error', async () => {
// 使用非法配置触发初始化错误(mode 未知)
const hook = useSqlarkDatabase({ name: 'vue-fail', mode: 'unknown-mode' as any });
await flushMounted();
expect(hook.db.value).toBeNull();
expect(hook.ready.value).toBe(false);
expect(hook.error.value).toBeInstanceOf(Error);
});
});
+372
View File
@@ -0,0 +1,372 @@
/**
* v0.3.0 SQL 功能扩展测试
* @module tests/sql-ext
*
* 覆盖:多语句 / 事务语句 / INSERT...SELECT / UNION / CREATE INDEX / EXISTS
*/
import 'fake-indexeddb/auto';
import { MetonaSqlark } from '../src/core';
import '../src/connection-manager';
import { parse, parseAll } from '../src/sql/parser';
async function createDb(mode: 'memory' | 'disk' | 'aria' = 'memory') {
const db = new MetonaSqlark({ name: `sql-ext-${mode}-${Date.now()}-${Math.random()}`, mode, diskEngine: 'indexeddb' });
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', 25, 'Shanghai')`);
await db.query(`INSERT INTO users VALUES ('u3', 'Carol', 35, 'Beijing')`);
await db.query(`INSERT INTO users VALUES ('u4', 'Dave', 28, 'Shenzhen')`);
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;
}
// ===================================================================
// 多语句 parseAll
// ===================================================================
describe('[v0.3.0] 多语句支持', () => {
test('parseAll 解析分号分隔的多条语句', () => {
const stmts = parseAll('SELECT * FROM a; INSERT INTO b VALUES (1); DELETE FROM c WHERE id = 1');
expect(stmts).toHaveLength(3);
expect(stmts[0].type).toBe('SELECT');
expect(stmts[1].type).toBe('INSERT');
expect(stmts[2].type).toBe('DELETE');
});
test('parseAll 忽略多余分号与尾部空语句', () => {
const stmts = parseAll(';;SELECT * FROM a;;;');
expect(stmts).toHaveLength(1);
expect(stmts[0].type).toBe('SELECT');
});
test('parseAll 支持事务语句', () => {
const stmts = parseAll('BEGIN; INSERT INTO a VALUES (1); COMMIT');
expect(stmts.map((s) => s.type)).toEqual(['BEGIN', 'INSERT', 'COMMIT']);
});
test('parse 保持单语句兼容', () => {
expect(parse('SELECT * FROM a').type).toBe('SELECT');
});
test('parseAll 语句间缺分号报错', () => {
expect(() => parseAll('SELECT * FROM a SELECT * FROM b')).toThrow(/Expected ';'/);
});
test('core.query 顺序执行多语句', async () => {
const db = await createDb();
await db.query('CREATE TABLE logs (id STRING PRIMARY KEY, msg STRING); INSERT INTO logs VALUES (\'l1\', \'hello\'); INSERT INTO logs VALUES (\'l2\', \'world\')');
const rows = await db.query('SELECT * FROM logs') as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.close();
});
});
// ===================================================================
// 事务语句 BEGIN / COMMIT / ROLLBACK
// ===================================================================
describe('[v0.3.0] 事务语句', () => {
test('BEGIN + INSERT + COMMIT 持久化', async () => {
const db = await createDb();
await db.query('BEGIN');
await db.query(`INSERT INTO users VALUES ('u5', 'Eve', 22, 'Beijing')`);
const visible = await db.query('SELECT * FROM users') as Record<string, unknown>[];
expect(visible).toHaveLength(5); // 事务内可见
await db.query('COMMIT');
const after = await db.query('SELECT * FROM users') as Record<string, unknown>[];
expect(after).toHaveLength(5);
await db.close();
});
test('BEGIN + INSERT + ROLLBACK 回滚', async () => {
const db = await createDb();
await db.query('BEGIN');
await db.query(`INSERT INTO users VALUES ('u5', 'Eve', 22, 'Beijing')`);
await db.query('ROLLBACK');
const after = await db.query('SELECT * FROM users') as Record<string, unknown>[];
expect(after).toHaveLength(4);
await db.close();
});
test('BEGIN 嵌套报错', async () => {
const db = await createDb();
await db.query('BEGIN');
await expect(db.query('BEGIN')).rejects.toThrow(/TX_ACTIVE|Transaction already/);
await db.query('ROLLBACK');
await db.close();
});
test('无事务时 COMMIT 报错', async () => {
const db = await createDb();
await expect(db.query('COMMIT')).rejects.toThrow(/TX_NONE|No active transaction/);
await db.close();
});
test('事务语句在 Aria 引擎上可用', async () => {
const db = await createDb('aria');
await db.query('BEGIN');
await db.query(`INSERT INTO users VALUES ('u5', 'Eve', 22, 'Beijing')`);
await db.query('ROLLBACK');
const after = await db.query('SELECT * FROM users') as Record<string, unknown>[];
expect(after).toHaveLength(4);
await db.close();
});
});
// ===================================================================
// INSERT INTO ... SELECT
// ===================================================================
describe('[v0.3.0] INSERT INTO ... SELECT', () => {
test('INSERT SELECT 全列复制', async () => {
const db = await createDb();
await db.defineTable('users_backup', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
age: { type: 'number' },
city: { type: 'string' },
});
await db.query('INSERT INTO users_backup SELECT * FROM users');
const rows = await db.query('SELECT * FROM users_backup') as Record<string, unknown>[];
expect(rows).toHaveLength(4);
expect(rows[0].name).toBe('Alice');
await db.close();
});
test('INSERT SELECT 带 WHERE 过滤', async () => {
const db = await createDb();
await db.defineTable('beijing_users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
age: { type: 'number' },
city: { type: 'string' },
});
await db.query(`INSERT INTO beijing_users SELECT * FROM users WHERE city = 'Beijing'`);
const rows = await db.query('SELECT * FROM beijing_users') as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.close();
});
test('INSERT SELECT 指定列映射', async () => {
const db = await createDb();
await db.defineTable('names', { id: { type: 'string', primaryKey: true }, n: { type: 'string' } });
await db.query('INSERT INTO names (id, n) SELECT id, name FROM users');
const rows = await db.query('SELECT * FROM names') as Record<string, unknown>[];
expect(rows).toHaveLength(4);
expect(rows[0].n).toBe('Alice');
await db.close();
});
test('INSERT SELECT 语法解析', () => {
const stmt = parse('INSERT INTO a (x, y) SELECT p, q FROM b WHERE r > 1') as any;
expect(stmt.type).toBe('INSERT');
expect(stmt.select).toBeDefined();
expect(stmt.select.type).toBe('SELECT');
expect(stmt.columns).toEqual(['x', 'y']);
});
});
// ===================================================================
// UNION / UNION ALL
// ===================================================================
describe('[v0.3.0] UNION / UNION ALL', () => {
test('UNION 去重合并', async () => {
const db = await createDb();
const rows = await db.query(`SELECT name FROM users WHERE city = 'Beijing' UNION SELECT name FROM users WHERE age < 30`) as Record<string, unknown>[];
// Beijing: Alice, CarolAlice 同时 age30 不重复);age<30: Bob, Dave
expect(rows).toHaveLength(4);
await db.close();
});
test('UNION ALL 不去重', async () => {
const db = await createDb();
const rows = await db.query(`SELECT city FROM users WHERE city = 'Beijing' UNION ALL SELECT city FROM users WHERE city = 'Beijing'`) as Record<string, unknown>[];
expect(rows).toHaveLength(4); // 2 + 2
await db.close();
});
test('UNION 对相同行去重', async () => {
const db = await createDb();
const rows = await db.query(`SELECT city FROM users WHERE city = 'Beijing' UNION SELECT city FROM users WHERE city = 'Beijing'`) as Record<string, unknown>[];
// 左右各 2 行同值 → 去重后 1 行
expect(rows).toHaveLength(1);
expect(rows[0].city).toBe('Beijing');
await db.close();
});
test('UNION 链式(三表合并)', async () => {
const db = await createDb();
await db.query(`INSERT INTO users VALUES ('u5', 'Eve', 22, 'Beijing')`);
const rows = await db.query(`SELECT city FROM users WHERE city = 'Beijing' UNION SELECT city FROM users WHERE city = 'Shanghai' UNION SELECT city FROM users WHERE city = 'Beijing'`) as Record<string, unknown>[];
// Beijing(3) + Shanghai(1) + Beijing(3 重复去重) → 2 个城市
expect(rows).toHaveLength(2);
await db.close();
});
test('UNION 语法解析', () => {
const stmt = parse('SELECT a FROM t1 UNION SELECT b FROM t2') as any;
expect(stmt.type).toBe('SELECT_UNION');
expect(stmt.all).toBeUndefined();
const stmtAll = parse('SELECT a FROM t1 UNION ALL SELECT b FROM t2') as any;
expect(stmtAll.type).toBe('SELECT_UNION');
expect(stmtAll.all).toBe(true);
});
test('UNION 在 Aria 引擎上可用', async () => {
const db = await createDb('aria');
const rows = await db.query(`SELECT city FROM users WHERE city = 'Beijing' UNION SELECT city FROM users WHERE city = 'Beijing'`) as Record<string, unknown>[];
expect(rows).toHaveLength(1);
await db.close();
});
});
// ===================================================================
// CREATE INDEX / DROP INDEX
// ===================================================================
describe('[v0.3.0] CREATE INDEX / DROP INDEX', () => {
test('CREATE INDEX 后索引查询可用(Memory', async () => {
const db = await createDb();
await db.query('CREATE INDEX idx_users_city ON users (city)');
const rows = await db.query(`SELECT * FROM users WHERE city = 'Beijing'`) as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.close();
});
test('CREATE INDEX 后索引查询可用(Aria', async () => {
const db = await createDb('aria');
await db.query('CREATE INDEX idx_users_city ON users (city)');
const rows = await db.query(`SELECT * FROM users WHERE city = 'Beijing'`) as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.close();
});
test('CREATE INDEX 对已有数据立即生效(索引填充)', async () => {
const db = await createDb();
await db.query('INSERT INTO users VALUES (\'u5\', \'Eve\', 22, \'Beijing\')');
await db.query('CREATE INDEX idx_users_age ON users (age)');
const rows = await db.query(`SELECT * FROM users WHERE age = 22`) as Record<string, unknown>[];
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe('u5');
await db.close();
});
test('DROP INDEX 后查询回退全表扫描', async () => {
const db = await createDb();
await db.query('CREATE INDEX idx_users_city ON users (city)');
await db.query('DROP INDEX idx_users_city ON users (city)');
const rows = await db.query(`SELECT * FROM users WHERE city = 'Beijing'`) as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.close();
});
test('CREATE INDEX 重复创建幂等', async () => {
const db = await createDb();
await db.query('CREATE INDEX idx_users_city ON users (city)');
await db.query('CREATE INDEX idx_users_city ON users (city)'); // 不抛错
const rows = await db.query(`SELECT * FROM users WHERE city = 'Beijing'`) as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.close();
});
test('CREATE INDEX 不存在列报错', async () => {
const db = await createDb();
const err = await db.query('CREATE INDEX idx_bad ON users (nonexistent)').catch((e: any) => e);
expect(err).toBeDefined();
expect(err.code).toBe('COLUMN_NOT_FOUND');
await db.close();
});
test('CREATE INDEX 后插入新数据索引同步更新', async () => {
const db = await createDb();
await db.query('CREATE INDEX idx_users_city ON users (city)');
await db.query('INSERT INTO users VALUES (\'u5\', \'Eve\', 22, \'Beijing\')');
const rows = await db.query(`SELECT * FROM users WHERE city = 'Beijing'`) as Record<string, unknown>[];
expect(rows).toHaveLength(3);
await db.close();
});
test('DROP INDEX Aria 主键索引受保护', async () => {
const db = await createDb('aria');
await expect(db.query('DROP INDEX pk ON users (id)')).rejects.toThrow(/Cannot drop primary key/);
await db.close();
});
test('CREATE UNIQUE INDEX 语法解析', () => {
const stmt = parse('CREATE UNIQUE INDEX idx_u ON users (email)') as any;
expect(stmt.type).toBe('CREATE_INDEX');
expect(stmt.unique).toBe(true);
expect(stmt.column).toBe('email');
});
test('CREATE INDEX / DROP INDEX 语法解析', () => {
const stmt = parse('CREATE INDEX idx_c ON users (city)') as any;
expect(stmt).toMatchObject({ type: 'CREATE_INDEX', name: 'idx_c', table: 'users', column: 'city' });
const drop = parse('DROP INDEX idx_c ON users (city)') as any;
expect(drop).toMatchObject({ type: 'DROP_INDEX', name: 'idx_c', table: 'users', column: 'city' });
});
});
// ===================================================================
// EXISTS / NOT EXISTS
// ===================================================================
describe('[v0.3.0] EXISTS / NOT EXISTS', () => {
test('EXISTS 子查询为真返回行', async () => {
const db = await createDb();
const rows = await db.query(`SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)`) as Record<string, unknown>[];
// 有订单的用户:u1, u2
expect(rows).toHaveLength(2);
expect(rows.map((r) => r.id).sort()).toEqual(['u1', 'u2']);
await db.close();
});
test('NOT EXISTS 返回无关联行', async () => {
const db = await createDb();
const rows = await db.query(`SELECT * FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)`) as Record<string, unknown>[];
// 无订单用户:u3, u4
expect(rows).toHaveLength(2);
expect(rows.map((r) => r.id).sort()).toEqual(['u3', 'u4']);
await db.close();
});
test('EXISTS 与 AND 组合', async () => {
const db = await createDb();
const rows = await db.query(`SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.amount > 150) AND u.city = 'Beijing'`) as Record<string, unknown>[];
// o2 金额 200 > 150 属于 u1Beijing
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe('u1');
await db.close();
});
test('EXISTS 语法解析', () => {
const stmt = parse(`SELECT * FROM users WHERE EXISTS (SELECT 1 FROM orders)`) as any;
expect(stmt.where.$exists).toBeDefined();
expect(stmt.where.$exists.$subquery.type).toBe('SELECT');
const notStmt = parse(`SELECT * FROM users WHERE NOT EXISTS (SELECT 1 FROM orders)`) as any;
expect(notStmt.where.$exists).toBeDefined();
expect(notStmt.where.$exists.$negate).toBe(true);
});
test('EXISTS 在 Aria 引擎上可用', async () => {
const db = await createDb('aria');
const rows = await db.query(`SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)`) as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.close();
});
});
+313
View File
@@ -0,0 +1,313 @@
/**
* v0.3.1 功能测试
* @module tests/sql-ext2
*
* 覆盖:CASE WHEN 表达式 / JOIN + 关联子查询 / WAL 批量组提交
*/
import 'fake-indexeddb/auto';
import { MetonaSqlark } from '../src/core';
import { parse } from '../src/sql/parser';
import { WAL, type WALStore } from '../src/engine/aria/wal/log';
import { WALRecordType } from '../src/engine/aria/types';
async function createDb(mode: 'memory' | 'aria' = 'memory') {
const db = new MetonaSqlark({ name: `sql-ext2-${mode}-${Date.now()}-${Math.random()}`, mode, diskEngine: 'indexeddb' });
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
// ===================================================================
describe('[v0.3.1] CASE WHEN', () => {
test('基本 CASE WHEN(单条件 + ELSE', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT name, CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END AS status FROM users`,
) as Record<string, unknown>[];
expect(rows).toHaveLength(3);
const byName = Object.fromEntries(rows.map((r) => [r.name, r]));
expect(byName['Alice'].status).toBe('adult');
expect(byName['Bob'].status).toBe('minor');
expect(byName['Carol'].status).toBe('adult');
await db.close();
});
test('多 WHEN 分支按顺序匹配', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT name, CASE WHEN age < 18 THEN 'teen' WHEN age < 40 THEN 'adult' ELSE 'senior' END AS age_group FROM users`,
) as Record<string, unknown>[];
const byName = Object.fromEntries(rows.map((r) => [r.name, r]));
expect(byName['Alice'].age_group).toBe('adult');
expect(byName['Bob'].age_group).toBe('teen');
expect(byName['Carol'].age_group).toBe('senior');
await db.close();
});
test('THEN 值为列引用', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT CASE WHEN age >= 18 THEN city ELSE 'underage' END AS location FROM users`,
) as Record<string, unknown>[];
const cities = rows.map((r) => r.location);
expect(cities).toContain('Beijing');
expect(cities).toContain('underage');
await db.close();
});
test('无 ELSE 时返回 null', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT name, CASE WHEN age >= 40 THEN 'senior' END AS tag FROM users`,
) as Record<string, unknown>[];
const byName = Object.fromEntries(rows.map((r) => [r.name, r]));
expect(byName['Carol'].tag).toBe('senior');
expect(byName['Alice'].tag).toBeNull();
await db.close();
});
test('字面量:数字 / 布尔 / 字符串', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT name, CASE WHEN age > 20 THEN 1 ELSE 0 END AS flag, CASE WHEN city = 'Beijing' THEN true ELSE false END AS is_bj FROM users`,
) as Record<string, unknown>[];
const byName = Object.fromEntries(rows.map((r) => [r.name, r]));
expect(byName['Alice'].flag).toBe(1);
expect(byName['Bob'].flag).toBe(0);
expect(byName['Alice'].is_bj).toBe(true);
expect(byName['Bob'].is_bj).toBe(false);
await db.close();
});
test('多条件组合(AND/OR', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT name, CASE WHEN age >= 18 AND city = 'Beijing' THEN 'local adult' ELSE 'other' END AS label FROM users`,
) as Record<string, unknown>[];
const byName = Object.fromEntries(rows.map((r) => [r.name, r]));
expect(byName['Alice'].label).toBe('local adult');
expect(byName['Carol'].label).toBe('local adult');
expect(byName['Bob'].label).toBe('other');
await db.close();
});
test('语法解析:CASE 列被解析为原文', () => {
const stmt = parse(`SELECT name, CASE WHEN age > 18 THEN 'x' ELSE 'y' END AS s FROM users`) as any;
expect(stmt.columns[1]).toMatch(/^CASE WHEN age > 18 THEN 'x' ELSE 'y' END AS s$/);
});
test('与普通列混合投影', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT name, age, CASE WHEN age >= 18 THEN 'ok' ELSE 'no' END AS adult FROM users`,
) as Record<string, unknown>[];
expect(rows[0].name).toBeDefined();
expect(rows[0].age).toBeDefined();
expect(rows[0].adult).toBeDefined();
expect(Object.keys(rows[0])).toEqual(expect.arrayContaining(['name', 'age', 'adult']));
await db.close();
});
test('Aria 引擎可用', async () => {
const db = await createDb('aria');
const rows = await db.query(
`SELECT name, CASE WHEN age >= 18 THEN 'adult' ELSE 'minor' END AS status FROM users`,
) as Record<string, unknown>[];
expect(rows).toHaveLength(3);
await db.close();
});
});
// ===================================================================
// JOIN + 关联子查询
// ===================================================================
describe('[v0.3.1] JOIN + 关联子查询', () => {
test('JOIN 结果上执行 EXISTS 关联过滤', async () => {
const db = await createDb();
// 有高额订单(>150)的用户
const rows = await db.query(
`SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id = u.id AND o2.amount > 150)`,
) as Record<string, unknown>[];
// 只有 u1 有 200 元订单
expect(rows).toHaveLength(2); // JOIN 展开 2 行(u1 有 2 个订单)
expect(rows.every((r) => r['u.name'] === 'Alice')).toBe(true);
await db.close();
});
test('JOIN + NOT EXISTS 排除关联行', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT DISTINCT u.name FROM users u LEFT JOIN orders o ON u.id = o.user_id WHERE NOT EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id = u.id)`,
) as Record<string, unknown>[];
// 无订单用户:u3 (Carol)JOIN 列键带表名前缀)
expect(rows.map((r) => r['u.name'])).toEqual(['Carol']);
await db.close();
});
test('JOIN + 关联子查询 + 普通条件组合', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id = u.id) AND o.amount > 50`,
) as Record<string, unknown>[];
// 有订单且订单 >50u1 的 o1(100), o2(200) + u2 的 o3(50 不满足)
expect(rows).toHaveLength(2);
expect(rows.every((r) => r['u.name'] === 'Alice')).toBe(true);
await db.close();
});
test('JOIN 子查询中的 $col 引用绑定外层行', async () => {
const db = await createDb();
const rows = await db.query(
`SELECT o.id, o.amount FROM orders o JOIN users u ON u.id = o.user_id WHERE EXISTS (SELECT 1 FROM orders o2 WHERE o2.amount > o.amount)`,
) as Record<string, unknown>[];
// 存在比自身金额更大的订单:o1(100) < o2(200) → o1 满足;o2 无更大;o3(50) < o1/o2 → 满足
const ids = rows.map((r) => r['o.id']).sort();
expect(ids).toEqual(['o1', 'o3']);
await db.close();
});
test('Aria 引擎 JOIN + EXISTS 可用', async () => {
const db = await createDb('aria');
const rows = await db.query(
`SELECT u.name FROM users u JOIN orders o ON u.id = o.user_id WHERE EXISTS (SELECT 1 FROM orders o2 WHERE o2.user_id = u.id AND o2.amount > 150)`,
) as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.close();
});
});
// ===================================================================
// WAL 批量组提交
// ===================================================================
describe('[v0.3.1] WAL 批量组提交', () => {
class MockWALStore implements WALStore {
chunks: Uint8Array[] = [];
appendCount = 0;
async append(data: Uint8Array) { this.chunks.push(data); this.appendCount++; }
async readAll(): Promise<Uint8Array> {
const total = this.chunks.reduce((s, c) => s + c.byteLength, 0);
const combined = new Uint8Array(total);
let off = 0;
for (const c of this.chunks) { combined.set(c, off); off += c.byteLength; }
return combined;
}
async truncate() { this.chunks = []; this.appendCount = 0; }
async exists() { return this.chunks.length > 0; }
}
test('appendBatch 合并为一次底层写入', async () => {
const store = new MockWALStore();
const wal = new WAL(store, true, 'full');
await wal.appendBatch([
{ type: WALRecordType.INSERT, txnId: 0, tableName: 't', key: '1', data: { v: 1 } },
{ type: WALRecordType.INSERT, txnId: 0, tableName: 't', key: '2', data: { v: 2 } },
{ type: WALRecordType.INSERT, txnId: 0, tableName: 't', key: '3', data: { v: 3 } },
]);
expect(store.appendCount).toBe(1); // 3 条记录 1 次写入
});
test('appendBatch 记录可恢复', async () => {
const store = new MockWALStore();
const wal = new WAL(store, true, 'full');
await wal.appendBatch([
{ type: WALRecordType.INSERT, txnId: 0, tableName: 'users', key: 'a', data: { n: 1 } },
{ type: WALRecordType.INSERT, txnId: 0, tableName: 'users', key: 'b', data: { n: 2 } },
]);
const records: { tableName: string; key: string }[] = [];
await wal.recover((r) => records.push(r));
expect(records).toHaveLength(2);
expect(records[0].key).toBe('a');
expect(records[1].key).toBe('b');
});
test('batch 模式 appendBatch 缓冲后 flush', async () => {
const store = new MockWALStore();
const wal = new WAL(store, true, 'batch');
await wal.appendBatch([
{ type: WALRecordType.INSERT, txnId: 0, tableName: 't', key: '1', data: {} },
{ type: WALRecordType.INSERT, txnId: 0, tableName: 't', key: '2', data: {} },
]);
expect(store.appendCount).toBe(0); // 缓冲未落盘
await wal.flush();
expect(store.appendCount).toBe(1);
});
test('append 与 appendBatch 共存', async () => {
const store = new MockWALStore();
const wal = new WAL(store, true, 'full');
await wal.append({ type: WALRecordType.BEGIN, txnId: 7, tableName: '', key: '' });
await wal.appendBatch([
{ type: WALRecordType.INSERT, txnId: 7, tableName: 't', key: '1', data: {} },
{ type: WALRecordType.INSERT, txnId: 7, tableName: 't', key: '2', data: {} },
]);
await wal.append({ type: WALRecordType.COMMIT, txnId: 7, tableName: '', key: '' });
expect(store.appendCount).toBe(3); // BEGIN + 批量(1) + COMMIT
const records: number[] = [];
await wal.recover((r) => records.push(r.type));
expect(records).toEqual([
WALRecordType.BEGIN,
WALRecordType.INSERT,
WALRecordType.INSERT,
WALRecordType.COMMIT,
]);
});
test('引擎 insert 批量写入只触发一次 WAL 落盘', async () => {
const db = new MetonaSqlark({ name: `wal-batch-${Date.now()}`, mode: 'aria', diskEngine: 'memory' });
await db.init();
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string' } });
const engine = db.getEngine() as any;
let before = 0;
const origRead = engine.backend.read.bind(engine.backend);
// 统计 __wal_ 写入次数
const origWrite = engine.backend.write.bind(engine.backend);
let walWrites = 0;
engine.backend.write = async (key: string, data: ArrayBuffer) => {
if (key.startsWith('__wal_') && !key.startsWith('__wal_count')) walWrites++;
return origWrite(key, data);
};
void before; void origRead;
await db.table('users').insertMany([
{ id: '1', name: 'A' },
{ id: '2', name: 'B' },
{ id: '3', name: 'C' },
{ id: '4', name: 'D' },
]);
expect(walWrites).toBe(1); // 4 行 1 次 WAL 写入
const rows = await db.query('SELECT * FROM users') as Record<string, unknown>[];
expect(rows).toHaveLength(4);
await db.close();
});
});
+406
View File
@@ -0,0 +1,406 @@
/**
* v0.3.2 功能测试
* @module tests/sql-ext3
*
* 覆盖:CASE WHEN 用于 WHERE/聚合 / JOIN 哈希连接 / 多标签页同步
*/
import 'fake-indexeddb/auto';
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: 'indexeddb',
...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: 'indexeddb' });
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: 'indexeddb', multiTabSync: true });
const dbB = new MetonaSqlark({ name: 'mt-a', version: 2, mode: 'hybrid', diskEngine: 'indexeddb', 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: 'indexeddb' });
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: 'indexeddb', multiTabSync: true });
const dbB = new MetonaSqlark({ name: 'mt-b', version: 2, mode: 'hybrid', diskEngine: 'indexeddb', 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: 'indexeddb' });
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: 'indexeddb' });
const dbB = new MetonaSqlark({ name: 'mt-c', version: 2, mode: 'hybrid', diskEngine: 'indexeddb', 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: 'indexeddb' });
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: 'indexeddb', multiTabSync: true });
const dbB = new MetonaSqlark({ name: 'mt-d', version: 2, mode: 'hybrid', diskEngine: 'indexeddb', 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] IndexedDB reopen schema 持久化', () => {
test('close 后重新 open 表结构与数据完整', async () => {
const setup = new MetonaSqlark({ name: 'reopen-a', mode: 'hybrid', diskEngine: 'indexeddb' });
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: 'indexeddb' });
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: 'indexeddb' });
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: 'indexeddb' });
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();
});
});
+99 -99
View File
@@ -1,99 +1,99 @@
/**
* utils.ts 单元测试模板
*/
import {
generateId,
escapeHTML,
debounce,
throttle,
deepMerge,
isBrowser,
} from '../src/utils';
describe('generateId', () => {
test('返回字符串', () => {
expect(typeof generateId()).toBe('string');
});
test('多次调用产生不同值', () => {
const ids = new Set<string>();
for (let i = 0; i < 100; i++) ids.add(generateId());
expect(ids.size).toBe(100);
});
});
describe('escapeHTML', () => {
test('转义 < > &', () => {
const out = escapeHTML('<a>&b</a>');
expect(out).toContain('&lt;');
expect(out).toContain('&gt;');
expect(out).toContain('&amp;');
});
test('null/undefined 返回空字符串', () => {
expect(escapeHTML(null)).toBe('');
expect(escapeHTML(undefined)).toBe('');
});
});
describe('debounce', () => {
test('延迟执行', (done) => {
let called = 0;
const fn = debounce(() => { called++; }, 20);
fn();
expect(called).toBe(0);
setTimeout(() => {
expect(called).toBe(1);
done();
}, 50);
});
test('多次调用只执行最后一次', (done) => {
let result = 0;
const fn = debounce((v: number) => { result = v; }, 20);
fn(1);
fn(2);
fn(3);
setTimeout(() => {
expect(result).toBe(3);
done();
}, 50);
});
});
describe('throttle', () => {
test('首次立即执行', () => {
let called = 0;
const fn = throttle(() => { called++; }, 50);
fn();
expect(called).toBe(1);
});
test('限流期内不重复执行', () => {
let called = 0;
const fn = throttle(() => { called++; }, 50);
fn();
fn();
fn();
expect(called).toBe(1);
});
});
describe('deepMerge', () => {
test('浅层合并', () => {
const out = deepMerge({ a: 1, b: 2 }, { b: 3, c: 4 });
expect(out).toEqual({ a: 1, b: 3, c: 4 });
});
test('深层对象合并', () => {
const out = deepMerge({ obj: { x: 1 } }, { obj: { y: 2 } });
expect(out).toEqual({ obj: { x: 1, y: 2 } });
});
});
describe('isBrowser', () => {
test('jsdom 环境下返回 true', () => {
expect(isBrowser()).toBe(true);
});
});
/**
* utils.ts 单元测试模板
*/
import {
generateId,
escapeHTML,
debounce,
throttle,
deepMerge,
isBrowser,
} from '../src/utils';
describe('generateId', () => {
test('返回字符串', () => {
expect(typeof generateId()).toBe('string');
});
test('多次调用产生不同值', () => {
const ids = new Set<string>();
for (let i = 0; i < 100; i++) ids.add(generateId());
expect(ids.size).toBe(100);
});
});
describe('escapeHTML', () => {
test('转义 < > &', () => {
const out = escapeHTML('<a>&b</a>');
expect(out).toContain('&lt;');
expect(out).toContain('&gt;');
expect(out).toContain('&amp;');
});
test('null/undefined 返回空字符串', () => {
expect(escapeHTML(null)).toBe('');
expect(escapeHTML(undefined)).toBe('');
});
});
describe('debounce', () => {
test('延迟执行', (done) => {
let called = 0;
const fn = debounce(() => { called++; }, 20);
fn();
expect(called).toBe(0);
setTimeout(() => {
expect(called).toBe(1);
done();
}, 50);
});
test('多次调用只执行最后一次', (done) => {
let result = 0;
const fn = debounce((v: number) => { result = v; }, 20);
fn(1);
fn(2);
fn(3);
setTimeout(() => {
expect(result).toBe(3);
done();
}, 50);
});
});
describe('throttle', () => {
test('首次立即执行', () => {
let called = 0;
const fn = throttle(() => { called++; }, 50);
fn();
expect(called).toBe(1);
});
test('限流期内不重复执行', () => {
let called = 0;
const fn = throttle(() => { called++; }, 50);
fn();
fn();
fn();
expect(called).toBe(1);
});
});
describe('deepMerge', () => {
test('浅层合并', () => {
const out = deepMerge({ a: 1, b: 2 }, { b: 3, c: 4 });
expect(out).toEqual({ a: 1, b: 3, c: 4 });
});
test('深层对象合并', () => {
const out = deepMerge({ obj: { x: 1 } }, { obj: { y: 2 } });
expect(out).toEqual({ obj: { x: 1, y: 2 } });
});
});
describe('isBrowser', () => {
test('jsdom 环境下返回 true', () => {
expect(isBrowser()).toBe(true);
});
});
+361 -374
View File
@@ -1,374 +1,361 @@
/**
* v0.2.5 修复验证测试
* 验证所有 P0/P1/P2 修复点
*/
import { VERSION } from '../src/constants';
import { MetonaSqlark } from '../src/core';
import { AriaEngine } from '../src/engine/aria/index';
import { MemoryEngine } from '../src/engine/memory';
import { parse } from '../src/sql/parser';
import { QueryExecutor } from '../src/query/executor';
import { WAL } from '../src/engine/aria/wal/log';
import { SSTableReader } from '../src/engine/aria/index/sstable';
import { BloomFilter } from '../src/engine/aria/index/bloom';
import { CryptoManager } from '../src/engine/aria/crypto';
import { PluginManager } from '../src/plugin/index';
import type { SSTableMeta } from '../src/engine/aria/types';
import type { MetonaPlugin } from '../src/constants';
import { createSchema } from '../src/table/schema';
// ---------------------------------------------------------------------------
// P0-1: 版本号统一
// ---------------------------------------------------------------------------
describe('[v0.2.5] P0-1: 版本号统一', () => {
test('VERSION 常量为 0.2.5', () => {
expect(VERSION).toBe('0.2.5');
});
});
// ---------------------------------------------------------------------------
// P0-2: AriaEngine OPFS 后端映射修复
// ---------------------------------------------------------------------------
describe('[v0.2.5] P0-2: AriaEngine OPFS 后端映射', () => {
test('mode=aria + diskEngine=opfs 时应使用 opfs 后端', () => {
const db = new MetonaSqlark({ name: 'test-opfs-map', mode: 'aria', diskEngine: 'opfs' });
// 不实际 open(需要浏览器环境),只验证 createEngine 逻辑
// 通过 getEngine 在 init 后检查
expect(db).toBeDefined();
});
test('mode=aria + diskEngine=indexeddb 时应使用 indexeddb 后端', () => {
const db = new MetonaSqlark({ name: 'test-idb-map', mode: 'aria', diskEngine: 'indexeddb' });
expect(db).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// P0-3: _onError 接入执行路径
// ---------------------------------------------------------------------------
describe('[v0.2.5] P0-3: _onError 接入执行路径', () => {
test('query 失败时调用 onError 回调', async () => {
const errors: Error[] = [];
const db = new MetonaSqlark({
name: 'test-onerror',
mode: 'memory',
onError: (e) => errors.push(e),
});
await db.init();
await db.defineTable('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
});
// 故意执行不存在的表查询
await expect(db.query('SELECT * FROM nonexistent')).rejects.toThrow();
expect(errors.length).toBeGreaterThan(0);
await db.close();
});
test('defineTable 失败时调用 onError', async () => {
const errors: Error[] = [];
const db = new MetonaSqlark({
name: 'test-onerror2',
mode: 'memory',
onError: (e) => errors.push(e),
});
await db.init();
await db.defineTable('dup', {
id: { type: 'string', primaryKey: true },
});
// 重复创建
await expect(db.defineTable('dup', {
id: { type: 'string', primaryKey: true },
})).rejects.toThrow();
expect(errors.length).toBeGreaterThan(0);
await db.close();
});
});
// ---------------------------------------------------------------------------
// P0-4: maxRowsPerQuery 生效
// ---------------------------------------------------------------------------
describe('[v0.2.5] P0-4: maxRowsPerQuery 生效', () => {
test('结果集被截断为 maxRowsPerQuery', async () => {
const engine = new MemoryEngine();
await engine.open('test-maxrows', 1);
await engine.createTable(createSchema('items', {
id: { type: 'string', primaryKey: true },
val: { type: 'number' },
}));
// 插入 100 行
for (let i = 0; i < 100; i++) {
await engine.insert('items', [{ id: `item${i}`, val: i }]);
}
const executor = new QueryExecutor(engine, 10); // maxRowsPerQuery=10
const stmt = parse('SELECT * FROM items');
const result = await executor.execute(stmt) as Record<string, unknown>[];
expect(result.length).toBe(10); // 截断为 10 行
await engine.close();
});
test('maxRowsPerQuery=0 表示不限制', async () => {
const engine = new MemoryEngine();
await engine.open('test-nolimit', 1);
await engine.createTable(createSchema('items', {
id: { type: 'string', primaryKey: true },
}));
for (let i = 0; i < 50; i++) {
await engine.insert('items', [{ id: `i${i}` }]);
}
const executor = new QueryExecutor(engine, 0);
const stmt = parse('SELECT * FROM items');
const result = await executor.execute(stmt) as Record<string, unknown>[];
expect(result.length).toBe(50);
await engine.close();
});
});
// ---------------------------------------------------------------------------
// P0-5: WAL full 模式真正同步
// ---------------------------------------------------------------------------
describe('[v0.2.5] P0-5: WAL full 模式同步', () => {
test('append 在 full 模式下是 async 且可 await', async () => {
let appendCount = 0;
const wal = new WAL({
append: async (_data: Uint8Array) => { appendCount++; },
readAll: async () => new Uint8Array(0),
truncate: async () => {},
exists: async () => false,
}, true, 'full');
// append 现在返回 Promise
await wal.append({
type: 1, // INSERT
txnId: 0,
tableName: 'test',
key: 'k1',
data: { v: 1 },
} as any);
expect(appendCount).toBe(1);
});
});
// ---------------------------------------------------------------------------
// P0-6: PluginManager.install 传 db 实例
// ---------------------------------------------------------------------------
describe('[v0.2.5] P0-6: PluginManager.install 传 db 实例', () => {
test('register 传 db 给 install', () => {
let receivedDb: unknown = null;
const plugin: MetonaPlugin = {
name: 'test-plugin',
install: (db) => { receivedDb = db; },
destroy: () => {},
};
const pm = new PluginManager();
const fakeDb = { name: 'fake' };
pm.register(plugin, fakeDb);
expect(receivedDb).toBe(fakeDb);
});
});
// ---------------------------------------------------------------------------
// P1-7: SSTableReader 二分查找统一
// ---------------------------------------------------------------------------
describe('[v0.2.5] P1-7: SSTableReader 二分查找', () => {
test('rangeScan 使用二分查找正确定位', () => {
// 构建一个 SSTable 手工
const { SSTableBuilder } = require('../src/engine/aria/index/sstable_builder');
const builder = new SSTableBuilder(4096);
// 添加足够多的条目以形成多个 block
for (let i = 0; i < 100; i++) {
const key = `key${String(i).padStart(5, '0')}`;
builder.add(key, { data: `value${i}` });
}
const { sstableData } = builder.build();
const meta: SSTableMeta = {
id: 1,
level: 0,
minKey: 'key00000',
maxKey: 'key00099',
blockCount: 1,
totalSize: sstableData.byteLength,
bloomData: null,
};
const reader = new SSTableReader(sstableData, meta);
// 精确查找
const result = reader.get('key00050');
expect(result).not.toBeNull();
expect((result as any).data).toBe('value50');
// 范围扫描
const collected: string[] = [];
reader.rangeScan('key00010', 'key00020', (k) => collected.push(k));
expect(collected.length).toBeGreaterThan(0);
expect(collected[0]).toBe('key00010');
});
});
// ---------------------------------------------------------------------------
// P1-8: SQL 注入防护 — 表名校验
// ---------------------------------------------------------------------------
describe('[v0.2.5] P1-8: SQL 注入防护', () => {
test('表名校验正则表达式正确', () => {
// 验证正则逻辑本身
const validName = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
expect(validName.test('users')).toBe(true);
expect(validName.test('user_table')).toBe(true);
expect(validName.test('_private')).toBe(true);
expect(validName.test('Table1')).toBe(true);
// 非法表名
expect(validName.test('users; DROP TABLE')).toBe(false);
expect(validName.test('1table')).toBe(false);
expect(validName.test('user.name')).toBe(false);
expect(validName.test('user name')).toBe(false);
expect(validName.test("'; DROP TABLE users; --")).toBe(false);
});
});
// ---------------------------------------------------------------------------
// P1-9: crypto 实例化
// ---------------------------------------------------------------------------
describe('[v0.2.5] P1-9: CryptoManager 实例化', () => {
test('CryptoManager 可以独立实例化', () => {
const cm1 = new CryptoManager();
const cm2 = new CryptoManager();
expect(cm1.enabled).toBe(false);
expect(cm2.enabled).toBe(false);
// 两个实例互不影响
expect(cm1).not.toBe(cm2);
});
});
// ---------------------------------------------------------------------------
// P2-14: ALTER TABLE 语法
// ---------------------------------------------------------------------------
describe('[v0.2.5] P2-14: ALTER TABLE', () => {
test('解析 ALTER TABLE ADD COLUMN', () => {
const stmt = parse('ALTER TABLE users ADD COLUMN email VARCHAR(255) UNIQUE');
expect(stmt.type).toBe('ALTER_TABLE');
expect((stmt as any).name).toBe('users');
expect((stmt as any).action).toBe('ADD');
expect((stmt as any).column.name).toBe('email');
});
test('解析 ALTER TABLE DROP COLUMN', () => {
const stmt = parse('ALTER TABLE users DROP COLUMN email');
expect(stmt.type).toBe('ALTER_TABLE');
expect((stmt as any).action).toBe('DROP');
expect((stmt as any).column.name).toBe('email');
});
test('执行 ALTER TABLE ADD COLUMN', async () => {
const engine = new MemoryEngine();
await engine.open('test-alter', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
}));
const executor = new QueryExecutor(engine);
const stmt = parse('ALTER TABLE users ADD COLUMN email VARCHAR(255)');
await executor.execute(stmt);
const schema = await engine.getTableSchema('users');
expect(schema!.columns.email).toBeDefined();
await engine.close();
});
test('执行 ALTER TABLE DROP COLUMN', async () => {
const engine = new MemoryEngine();
await engine.open('test-alter-drop', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
email: { type: 'string' },
}));
const executor = new QueryExecutor(engine);
const stmt = parse('ALTER TABLE users DROP COLUMN email');
await executor.execute(stmt);
const schema = await engine.getTableSchema('users');
expect(schema!.columns.email).toBeUndefined();
await engine.close();
});
});
// ---------------------------------------------------------------------------
// P2-15: TRUNCATE TABLE 语法
// ---------------------------------------------------------------------------
describe('[v0.2.5] P2-15: TRUNCATE TABLE', () => {
test('解析 TRUNCATE TABLE', () => {
const stmt = parse('TRUNCATE TABLE users');
expect(stmt.type).toBe('TRUNCATE_TABLE');
expect((stmt as any).name).toBe('users');
});
test('执行 TRUNCATE TABLE 清空数据', async () => {
const engine = new MemoryEngine();
await engine.open('test-truncate', 1);
await engine.createTable(createSchema('items', {
id: { type: 'string', primaryKey: true },
}));
await engine.insert('items', [
{ id: 'a' }, { id: 'b' }, { id: 'c' },
]);
const executor = new QueryExecutor(engine);
const stmt = parse('TRUNCATE TABLE items');
await executor.execute(stmt);
const rows = await engine.find('items', { table: 'items' });
expect(rows.length).toBe(0);
await engine.close();
});
});
// ---------------------------------------------------------------------------
// P2-12: WAL 大小阈值接入 checkpoint
// ---------------------------------------------------------------------------
describe('[v0.2.5] P2-12: WAL 大小阈值', () => {
test('CheckpointManager 接收 walSizeThreshold 参数', () => {
const { CheckpointManager } = require('../src/engine/aria/wal/checkpoint');
const fakeLsm = { flush: async () => {} };
const fakeWal = { flush: async () => {}, checkpoint: async () => {}, getBufferedCount: () => 0 };
const cm = new CheckpointManager(fakeLsm, fakeWal, null, 1000, 1024);
expect(cm).toBeDefined();
expect(cm.getOpCount()).toBe(0);
});
});
// ---------------------------------------------------------------------------
// P2-13: compactLevelSync 接口公开化
// ---------------------------------------------------------------------------
describe('[v0.2.5] P2-13: compactLevel public', () => {
test('LSM.compactLevel 是 public 方法', () => {
const { LSM } = require('../src/engine/aria/index/lsm');
const lsm = new LSM({
sstableStore: {
save: async () => {}, load: async () => null, delete: async () => {},
allocateId: async () => 1, listMeta: async () => [], saveMeta: async () => {}, deleteMeta: async () => {},
},
});
expect(typeof lsm.compactLevel).toBe('function');
});
});
/**
* v0.2.5 修复验证测试
* 验证所有 P0/P1/P2 修复点
*/
import { VERSION } from '../src/constants';
import { MetonaSqlark } from '../src/core';
import { AriaEngine } from '../src/engine/aria/index';
import { MemoryEngine } from '../src/engine/memory';
import { parse } from '../src/sql/parser';
import { QueryExecutor } from '../src/query/executor';
import { WAL } from '../src/engine/aria/wal/log';
import { SSTableReader } from '../src/engine/aria/index/sstable';
import { BloomFilter } from '../src/engine/aria/index/bloom';
import { CryptoManager } from '../src/engine/aria/crypto';
import { PluginManager } from '../src/plugin/index';
import type { SSTableMeta } from '../src/engine/aria/types';
import type { MetonaPlugin } from '../src/constants';
import { createSchema } from '../src/table/schema';
// ---------------------------------------------------------------------------
// P0-1: 版本号统一
// ---------------------------------------------------------------------------
describe('[v0.2.5] P0-1: 版本号统一', () => {
test('VERSION 常量为当前版本(0.3.2', () => {
expect(VERSION).toBe('0.3.2');
});
});
// ---------------------------------------------------------------------------
// P0-2: AriaEngine OPFS 后端映射修复
// ---------------------------------------------------------------------------
describe('[v0.2.5] P0-2: AriaEngine OPFS 后端映射', () => {
test('mode=aria + diskEngine=opfs 时应使用 opfs 后端', () => {
const db = new MetonaSqlark({ name: 'test-opfs-map', mode: 'aria', diskEngine: 'opfs' });
// 不实际 open(需要浏览器环境),只验证 createEngine 逻辑
// 通过 getEngine 在 init 后检查
expect(db).toBeDefined();
});
test('mode=aria + diskEngine=indexeddb 时应使用 indexeddb 后端', () => {
const db = new MetonaSqlark({ name: 'test-idb-map', mode: 'aria', diskEngine: 'indexeddb' });
expect(db).toBeDefined();
});
});
// ---------------------------------------------------------------------------
// P0-3: _onError 接入执行路径
// ---------------------------------------------------------------------------
describe('[v0.2.5] P0-3: _onError 接入执行路径', () => {
test('query 失败时调用 onError 回调', async () => {
const errors: Error[] = [];
const db = new MetonaSqlark({
name: 'test-onerror',
mode: 'memory',
onError: (e) => errors.push(e),
});
await db.init();
await db.defineTable('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
});
// 故意执行不存在的表查询
await expect(db.query('SELECT * FROM nonexistent')).rejects.toThrow();
expect(errors.length).toBeGreaterThan(0);
await db.close();
});
test('defineTable 失败时调用 onError', async () => {
const errors: Error[] = [];
const db = new MetonaSqlark({
name: 'test-onerror2',
mode: 'memory',
onError: (e) => errors.push(e),
});
await db.init();
await db.defineTable('dup', {
id: { type: 'string', primaryKey: true },
});
// 重复创建
await expect(db.defineTable('dup', {
id: { type: 'string', primaryKey: true },
})).rejects.toThrow();
expect(errors.length).toBeGreaterThan(0);
await db.close();
});
});
// ---------------------------------------------------------------------------
// P0-4: maxRowsPerQuery 生效
// ---------------------------------------------------------------------------
describe('[v0.2.5] P0-4: maxRowsPerQuery 生效', () => {
test('结果集被截断为 maxRowsPerQuery', async () => {
const engine = new MemoryEngine();
await engine.open('test-maxrows', 1);
await engine.createTable(createSchema('items', {
id: { type: 'string', primaryKey: true },
val: { type: 'number' },
}));
// 插入 100 行
for (let i = 0; i < 100; i++) {
await engine.insert('items', [{ id: `item${i}`, val: i }]);
}
const executor = new QueryExecutor(engine, 10); // maxRowsPerQuery=10
const stmt = parse('SELECT * FROM items');
const result = await executor.execute(stmt) as Record<string, unknown>[];
expect(result.length).toBe(10); // 截断为 10 行
await engine.close();
});
test('maxRowsPerQuery=0 表示不限制', async () => {
const engine = new MemoryEngine();
await engine.open('test-nolimit', 1);
await engine.createTable(createSchema('items', {
id: { type: 'string', primaryKey: true },
}));
for (let i = 0; i < 50; i++) {
await engine.insert('items', [{ id: `i${i}` }]);
}
const executor = new QueryExecutor(engine, 0);
const stmt = parse('SELECT * FROM items');
const result = await executor.execute(stmt) as Record<string, unknown>[];
expect(result.length).toBe(50);
await engine.close();
});
});
// ---------------------------------------------------------------------------
// P0-5: WAL full 模式真正同步
// ---------------------------------------------------------------------------
describe('[v0.2.5] P0-5: WAL full 模式同步', () => {
test('append 在 full 模式下是 async 且可 await', async () => {
let appendCount = 0;
const wal = new WAL({
append: async (_data: Uint8Array) => { appendCount++; },
readAll: async () => new Uint8Array(0),
truncate: async () => {},
exists: async () => false,
}, true, 'full');
// append 现在返回 Promise
await wal.append({
type: 1, // INSERT
txnId: 0,
tableName: 'test',
key: 'k1',
data: { v: 1 },
} as any);
expect(appendCount).toBe(1);
});
});
// ---------------------------------------------------------------------------
// P0-6: PluginManager.install 传 db 实例
// ---------------------------------------------------------------------------
describe('[v0.2.5] P0-6: PluginManager.install 传 db 实例', () => {
test('register 传 db 给 install', () => {
let receivedDb: unknown = null;
const plugin: MetonaPlugin = {
name: 'test-plugin',
install: (db) => { receivedDb = db; },
destroy: () => {},
};
const pm = new PluginManager();
const fakeDb = { name: 'fake' };
pm.register(plugin, fakeDb);
expect(receivedDb).toBe(fakeDb);
});
});
// ---------------------------------------------------------------------------
// P1-7: SSTableReader 二分查找统一
// ---------------------------------------------------------------------------
describe('[v0.2.5] P1-7: SSTableReader 二分查找', () => {
test('rangeScan 使用二分查找正确定位', () => {
// 构建一个 SSTable 手工
const { SSTableBuilder } = require('../src/engine/aria/index/sstable_builder');
const builder = new SSTableBuilder(4096);
// 添加足够多的条目以形成多个 block
for (let i = 0; i < 100; i++) {
const key = `key${String(i).padStart(5, '0')}`;
builder.add(key, { data: `value${i}` });
}
const { sstableData } = builder.build();
const meta: SSTableMeta = {
id: 1,
level: 0,
minKey: 'key00000',
maxKey: 'key00099',
blockCount: 1,
totalSize: sstableData.byteLength,
bloomData: null,
};
const reader = new SSTableReader(sstableData, meta);
// 精确查找
const result = reader.get('key00050');
expect(result).not.toBeNull();
expect((result as any).data).toBe('value50');
// 范围扫描
const collected: string[] = [];
reader.rangeScan('key00010', 'key00020', (k) => collected.push(k));
expect(collected.length).toBeGreaterThan(0);
expect(collected[0]).toBe('key00010');
});
});
// ---------------------------------------------------------------------------
// P1-8: SQL 注入防护 — 表名校验
// 真实覆盖位置:tests/integrations/react.test.tsuseTable
// tests/integrations/vue.test.tsuseSqlarkTable
// 通过真实 hooks 调用触发 validateTableName,验证非法表名抛错。
// 注:v0.2.6 移除此处"复制正则自测"的伪测试(未触达真实代码)。
// ---------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// P1-9: crypto 实例化
// ---------------------------------------------------------------------------
describe('[v0.2.5] P1-9: CryptoManager 实例化', () => {
test('CryptoManager 可以独立实例化', () => {
const cm1 = new CryptoManager();
const cm2 = new CryptoManager();
expect(cm1.enabled).toBe(false);
expect(cm2.enabled).toBe(false);
// 两个实例互不影响
expect(cm1).not.toBe(cm2);
});
});
// ---------------------------------------------------------------------------
// P2-14: ALTER TABLE 语法
// ---------------------------------------------------------------------------
describe('[v0.2.5] P2-14: ALTER TABLE', () => {
test('解析 ALTER TABLE ADD COLUMN', () => {
const stmt = parse('ALTER TABLE users ADD COLUMN email VARCHAR(255) UNIQUE');
expect(stmt.type).toBe('ALTER_TABLE');
expect((stmt as any).name).toBe('users');
expect((stmt as any).action).toBe('ADD');
expect((stmt as any).column.name).toBe('email');
});
test('解析 ALTER TABLE DROP COLUMN', () => {
const stmt = parse('ALTER TABLE users DROP COLUMN email');
expect(stmt.type).toBe('ALTER_TABLE');
expect((stmt as any).action).toBe('DROP');
expect((stmt as any).column.name).toBe('email');
});
test('执行 ALTER TABLE ADD COLUMN', async () => {
const engine = new MemoryEngine();
await engine.open('test-alter', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
}));
const executor = new QueryExecutor(engine);
const stmt = parse('ALTER TABLE users ADD COLUMN email VARCHAR(255)');
await executor.execute(stmt);
const schema = await engine.getTableSchema('users');
expect(schema!.columns.email).toBeDefined();
await engine.close();
});
test('执行 ALTER TABLE DROP COLUMN', async () => {
const engine = new MemoryEngine();
await engine.open('test-alter-drop', 1);
await engine.createTable(createSchema('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
email: { type: 'string' },
}));
const executor = new QueryExecutor(engine);
const stmt = parse('ALTER TABLE users DROP COLUMN email');
await executor.execute(stmt);
const schema = await engine.getTableSchema('users');
expect(schema!.columns.email).toBeUndefined();
await engine.close();
});
});
// ---------------------------------------------------------------------------
// P2-15: TRUNCATE TABLE 语法
// ---------------------------------------------------------------------------
describe('[v0.2.5] P2-15: TRUNCATE TABLE', () => {
test('解析 TRUNCATE TABLE', () => {
const stmt = parse('TRUNCATE TABLE users');
expect(stmt.type).toBe('TRUNCATE_TABLE');
expect((stmt as any).name).toBe('users');
});
test('执行 TRUNCATE TABLE 清空数据', async () => {
const engine = new MemoryEngine();
await engine.open('test-truncate', 1);
await engine.createTable(createSchema('items', {
id: { type: 'string', primaryKey: true },
}));
await engine.insert('items', [
{ id: 'a' }, { id: 'b' }, { id: 'c' },
]);
const executor = new QueryExecutor(engine);
const stmt = parse('TRUNCATE TABLE items');
await executor.execute(stmt);
const rows = await engine.find('items', { table: 'items' });
expect(rows.length).toBe(0);
await engine.close();
});
});
// ---------------------------------------------------------------------------
// P2-12: WAL 大小阈值接入 checkpoint
// ---------------------------------------------------------------------------
describe('[v0.2.5] P2-12: WAL 大小阈值', () => {
test('CheckpointManager 接收 walSizeThreshold 参数', () => {
const { CheckpointManager } = require('../src/engine/aria/wal/checkpoint');
const fakeLsm = { flush: async () => {} };
const fakeWal = { flush: async () => {}, checkpoint: async () => {}, getBufferedCount: () => 0 };
const cm = new CheckpointManager(fakeLsm, fakeWal, null, 1000, 1024);
expect(cm).toBeDefined();
expect(cm.getOpCount()).toBe(0);
});
});
// ---------------------------------------------------------------------------
// P2-13: compactLevelSync 接口公开化
// ---------------------------------------------------------------------------
describe('[v0.2.5] P2-13: compactLevel public', () => {
test('LSM.compactLevel 是 public 方法', () => {
const { LSM } = require('../src/engine/aria/index/lsm');
const lsm = new LSM({
sstableStore: {
save: async () => {}, load: async () => null, delete: async () => {},
allocateId: async () => 1, listMeta: async () => [], saveMeta: async () => {}, deleteMeta: async () => {},
},
});
expect(typeof lsm.compactLevel).toBe('function');
});
});