release: v0.4.1 — Aria 级联/ALTER/clearAll + 流式查询/派生表 + 正确性加固
新增: - AriaEngine 外键级联(CASCADE/SET NULL/RESTRICT)+ clearAll() 重置 API - 引擎级 alterTable:Aria DROP COLUMN 重写存储行 + schema 持久化 - 流式查询 queryStream / findStream(LSM 惰性扫描不物化) - FROM 派生表 / 多列 ON 哈希连接 / COUNT(DISTINCT) / NULLS FIRST/LAST - 普通列别名 + ORDER BY 别名 + 无表查询 + 字符串常量列 - 演示页引擎切换器(Memory/Aria)+ 预设自动重置 修复: - Aria WAL DROP_TABLE 崩溃恢复(删表复活)+ 恢复后 WAL 截断 - Memory update/delete 索引维护(unique 约束绕过) - 关联 EXISTS 绑定失效 / HAVING 标量子查询 / INSERT SELECT 位置错位 - 裸布尔列条件(WHERE done / CASE WHEN done) - Aria $in 重复行 / JOIN 主表 WHERE 下推 / DROP INDEX 报错 - ORDER BY/GROUP BY/SELECT 表前缀列 + SQL '' 标准转义 质量:894 测试 · 47 套件 · 81.5% 覆盖率
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* v0.4.1 测试 — AriaEngine 外键级联 + clearAll 重置
|
||||
*/
|
||||
|
||||
import 'fake-indexeddb/auto';
|
||||
import { AriaEngine } from '../src/engine/aria/index';
|
||||
|
||||
const mkEngine = async (name: string): Promise<AriaEngine> => {
|
||||
const e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||
await e.open(`cascade-${name}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, 1);
|
||||
return e;
|
||||
};
|
||||
|
||||
describe('[v0.4.1] AriaEngine 外键级联', () => {
|
||||
test('CASCADE:删除主表行时级联删除引用行', async () => {
|
||||
const e = await mkEngine('cas');
|
||||
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true } } });
|
||||
await e.createTable({ name: 'orders', columns: { id: { type: 'string', primaryKey: true }, user_id: { type: 'string', references: 'users.id', onDelete: 'CASCADE' } } });
|
||||
await e.insert('users', [{ id: 'u1' }, { id: 'u2' }]);
|
||||
await e.insert('orders', [{ id: 'o1', user_id: 'u1' }, { id: 'o2', user_id: 'u1' }, { id: 'o3', user_id: 'u2' }]);
|
||||
|
||||
const count = await e.delete('users', { table: 'users', where: { id: 'u1' } });
|
||||
expect(count).toBe(3); // u1 + o1 + o2
|
||||
expect(await e.count('users')).toBe(1);
|
||||
expect(await e.count('orders')).toBe(1);
|
||||
const rest = await e.find('orders', { table: 'orders' });
|
||||
expect(rest[0].user_id).toBe('u2');
|
||||
await e.close();
|
||||
});
|
||||
|
||||
test('CASCADE:多层递归(users → orders → order_items)', async () => {
|
||||
const e = await mkEngine('cas2');
|
||||
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true } } });
|
||||
await e.createTable({ name: 'orders', columns: { id: { type: 'string', primaryKey: true }, user_id: { type: 'string', references: 'users.id', onDelete: 'CASCADE' } } });
|
||||
await e.createTable({ name: 'order_items', columns: { id: { type: 'string', primaryKey: true }, order_id: { type: 'string', references: 'orders.id', onDelete: 'CASCADE' } } });
|
||||
await e.insert('users', [{ id: 'u1' }]);
|
||||
await e.insert('orders', [{ id: 'o1', user_id: 'u1' }]);
|
||||
await e.insert('order_items', [{ id: 'i1', order_id: 'o1' }, { id: 'i2', order_id: 'o1' }]);
|
||||
|
||||
const count = await e.delete('users', { table: 'users', where: { id: 'u1' } });
|
||||
expect(count).toBe(4); // u1 + o1 + i1 + i2
|
||||
expect(await e.count('order_items')).toBe(0);
|
||||
await e.close();
|
||||
});
|
||||
|
||||
test('SET NULL:删除主表行时引用列置 null', async () => {
|
||||
const e = await mkEngine('sn');
|
||||
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true } } });
|
||||
await e.createTable({ name: 'orders', columns: { id: { type: 'string', primaryKey: true }, user_id: { type: 'string', references: 'users.id', onDelete: 'SET NULL' } } });
|
||||
await e.insert('users', [{ id: 'u1' }]);
|
||||
await e.insert('orders', [{ id: 'o1', user_id: 'u1' }]);
|
||||
|
||||
const count = await e.delete('users', { table: 'users', where: { id: 'u1' } });
|
||||
expect(count).toBe(1); // 仅 u1,引用行保留
|
||||
const rows = await e.find('orders', { table: 'orders' });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].user_id).toBeNull();
|
||||
await e.close();
|
||||
});
|
||||
|
||||
test('RESTRICT:存在引用行时禁止删除', async () => {
|
||||
const e = await mkEngine('res');
|
||||
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true } } });
|
||||
await e.createTable({ name: 'orders', columns: { id: { type: 'string', primaryKey: true }, user_id: { type: 'string', references: 'users.id', onDelete: 'RESTRICT' } } });
|
||||
await e.insert('users', [{ id: 'u1' }]);
|
||||
await e.insert('orders', [{ id: 'o1', user_id: 'u1' }]);
|
||||
|
||||
await expect(e.delete('users', { table: 'users', where: { id: 'u1' } })).rejects.toThrow('Cannot delete');
|
||||
// 数据未被删除
|
||||
expect(await e.count('users')).toBe(1);
|
||||
expect(await e.count('orders')).toBe(1);
|
||||
await e.close();
|
||||
});
|
||||
|
||||
test('CASCADE 同时清理二级索引(删除后按索引查不到)', async () => {
|
||||
const e = await mkEngine('cidx');
|
||||
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true } } });
|
||||
await e.createTable({ name: 'orders', columns: { id: { type: 'string', primaryKey: true }, user_id: { type: 'string', index: true, references: 'users.id', onDelete: 'CASCADE' } } });
|
||||
await e.insert('users', [{ id: 'u1' }]);
|
||||
await e.insert('orders', [{ id: 'o1', user_id: 'u1' }]);
|
||||
await e.delete('users', { table: 'users', where: { id: 'u1' } });
|
||||
const rows = await e.find('orders', { table: 'orders', where: { user_id: 'u1' } });
|
||||
expect(rows).toHaveLength(0);
|
||||
await e.close();
|
||||
});
|
||||
|
||||
test('事务内级联删除可提交/回滚', async () => {
|
||||
const e = await mkEngine('ctx');
|
||||
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true } } });
|
||||
await e.createTable({ name: 'orders', columns: { id: { type: 'string', primaryKey: true }, user_id: { type: 'string', references: 'users.id', onDelete: 'CASCADE' } } });
|
||||
await e.insert('users', [{ id: 'u1' }]);
|
||||
await e.insert('orders', [{ id: 'o1', user_id: 'u1' }]);
|
||||
|
||||
// rollback:级联删除被回滚
|
||||
await e.beginTransaction();
|
||||
await e.delete('users', { table: 'users', where: { id: 'u1' } });
|
||||
await e.rollbackTransaction();
|
||||
expect(await e.count('users')).toBe(1);
|
||||
expect(await e.count('orders')).toBe(1);
|
||||
|
||||
// commit:级联删除生效
|
||||
await e.beginTransaction();
|
||||
await e.delete('users', { table: 'users', where: { id: 'u1' } });
|
||||
await e.commitTransaction();
|
||||
expect(await e.count('users')).toBe(0);
|
||||
expect(await e.count('orders')).toBe(0);
|
||||
await e.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('[v0.4.1] AriaEngine clearAll', () => {
|
||||
test('clearAll 清空全部表与数据,实例可继续使用', async () => {
|
||||
const e = await mkEngine('clr');
|
||||
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string', index: true } } });
|
||||
await e.insert('users', [{ id: '1', name: 'Alice' }]);
|
||||
|
||||
await e.clearAll();
|
||||
expect(await e.getTableNames()).toHaveLength(0);
|
||||
|
||||
// 实例可继续建表使用
|
||||
await e.createTable({ name: 't2', columns: { id: { type: 'string', primaryKey: true } } });
|
||||
await e.insert('t2', [{ id: 'x' }]);
|
||||
expect(await e.count('t2')).toBe(1);
|
||||
await e.close();
|
||||
});
|
||||
|
||||
test('clearAll 后重启(模拟刷新)无残留数据', async () => {
|
||||
const name = `clr2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
let e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||
await e.open(name, 1);
|
||||
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
|
||||
await e.insert('t', [{ id: '1' }]);
|
||||
await e.clearAll();
|
||||
await e.close();
|
||||
|
||||
// 重新打开(模拟页面刷新):不应有残留表
|
||||
e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||
await e.open(name, 1);
|
||||
expect(await e.getTableNames()).toHaveLength(0);
|
||||
await e.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('[v0.4.1] AriaEngine ALTER TABLE', () => {
|
||||
test('DROP COLUMN 真正清除存储中的列值(find 副本不再残留)', async () => {
|
||||
const e = await mkEngine('alt');
|
||||
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string' }, email: { type: 'string' }, age: { type: 'number', default: 0 } } });
|
||||
await e.insert('users', [{ id: '1', name: 'Alice', email: 'a@x.com', age: 30 }]);
|
||||
await e.alterTable('users', 'ADD', { name: 'phone', type: 'string' });
|
||||
await e.insert('users', [{ id: '2', name: 'Frank', age: 33, phone: '123' }]);
|
||||
await e.alterTable('users', 'DROP', { name: 'phone', type: 'string' });
|
||||
|
||||
const rows = await e.find('users', { table: 'users' });
|
||||
for (const row of rows) {
|
||||
expect('phone' in row).toBe(false);
|
||||
}
|
||||
await e.close();
|
||||
});
|
||||
|
||||
test('ALTER 持久化:重启后 schema 与行一致', async () => {
|
||||
const name = `alt2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
let e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||
await e.open(name, 1);
|
||||
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string' } } });
|
||||
await e.insert('users', [{ id: '1', name: 'Alice' }]);
|
||||
await e.alterTable('users', 'ADD', { name: 'phone', type: 'string' });
|
||||
await e.insert('users', [{ id: '2', name: 'Frank', phone: '123' }]);
|
||||
await e.alterTable('users', 'DROP', { name: 'phone', type: 'string' });
|
||||
await e.close();
|
||||
|
||||
e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||
await e.open(name, 1);
|
||||
const schema = await e.getTableSchema('users');
|
||||
expect(Object.keys(schema!.columns)).not.toContain('phone');
|
||||
const rows = await e.find('users', { table: 'users' });
|
||||
for (const row of rows) expect('phone' in row).toBe(false);
|
||||
await e.close();
|
||||
});
|
||||
|
||||
test('ALTER 模拟崩溃(不 close)重启:schema 与行一致', async () => {
|
||||
const name = `alt3-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
let e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||
await e.open(name, 1);
|
||||
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string' } } });
|
||||
await e.insert('users', [{ id: '1', name: 'Alice' }]);
|
||||
await e.alterTable('users', 'ADD', { name: 'phone', type: 'string' });
|
||||
await e.insert('users', [{ id: '2', name: 'Frank', phone: '123' }]);
|
||||
await e.alterTable('users', 'DROP', { name: 'phone', type: 'string' });
|
||||
// 不 close,模拟崩溃
|
||||
|
||||
e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||
await e.open(name, 1);
|
||||
const schema = await e.getTableSchema('users');
|
||||
expect(Object.keys(schema!.columns)).not.toContain('phone');
|
||||
const rows = await e.find('users', { table: 'users' });
|
||||
for (const row of rows) expect('phone' in row).toBe(false);
|
||||
await e.close();
|
||||
});
|
||||
});
|
||||
@@ -23,8 +23,8 @@ import { createSchema } from '../src/table/schema';
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('[v0.2.5] P0-1: 版本号统一', () => {
|
||||
test('VERSION 常量为当前版本(0.3.2)', () => {
|
||||
expect(VERSION).toBe('0.3.2');
|
||||
test('VERSION 常量为当前版本(0.4.1)', () => {
|
||||
expect(VERSION).toBe('0.4.1');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* v0.3.3 修复验证测试
|
||||
* 验证所有 P0/P1 修复点:
|
||||
* - P0-1: Aria WAL DROP_TABLE 崩溃恢复(表/数据不复活)+ WAL 恢复后截断
|
||||
* - P0-2: MemoryEngine update/delete 索引维护(unique 约束 + 索引查询 + 级联清理)
|
||||
* - P1-3: Aria 事务内读到自己写入的变更(insert 后 update/delete)
|
||||
* - P1-4: Aria clear() 写 WAL + 事务化
|
||||
* - P1-5: WAL 字节数跟踪(full 模式 walSizeThreshold 生效)
|
||||
* - P1-6: Aria 主键列不建冗余二级索引(PK $in / 范围查询走主 LSM)
|
||||
* - P1-7: ORDER BY 支持 SELECT 别名
|
||||
* - P1-8: SQL 字符串 '' 标准转义
|
||||
* - P1-9: Savepoint 回滚后 MVCC 版本链一致 + rollback 索引重建
|
||||
*/
|
||||
|
||||
import 'fake-indexeddb/auto';
|
||||
import { VERSION } from '../src/constants';
|
||||
import { MetonaSqlark } from '../src/core';
|
||||
import { AriaEngine } from '../src/engine/aria/index';
|
||||
import { MemoryEngine } from '../src/engine/memory';
|
||||
import { WAL } from '../src/engine/aria/wal/log';
|
||||
import { tokenize } from '../src/sql/lexer';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P0-1: Aria WAL DROP_TABLE 崩溃恢复
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('[v0.3.3] P0-1: WAL DROP_TABLE 崩溃恢复', () => {
|
||||
const mkEngine = async (name: string): Promise<AriaEngine> => {
|
||||
const e = new AriaEngine({ storageBackend: 'indexeddb', walSyncMode: 'full' });
|
||||
await e.open(name, 1);
|
||||
return e;
|
||||
};
|
||||
|
||||
test('删表后崩溃(不 close),重启后表与数据不复活', async () => {
|
||||
const dbName = `crash-drop-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
let e = await mkEngine(dbName);
|
||||
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
|
||||
await e.insert('t', [{ id: '1' }, { id: '2' }]);
|
||||
await e.dropTable('t');
|
||||
|
||||
// 模拟崩溃:不 close,直接重建引擎(WAL 未 checkpoint)
|
||||
e = await mkEngine(dbName);
|
||||
const names = await e.getTableNames();
|
||||
expect(names).not.toContain('t');
|
||||
await e.close();
|
||||
});
|
||||
|
||||
test('删表崩溃后重建同名表,旧数据不复活', async () => {
|
||||
const dbName = `crash-drop2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
let e = await mkEngine(dbName);
|
||||
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
|
||||
await e.insert('t', [{ id: '1' }, { id: '2' }]);
|
||||
await e.dropTable('t');
|
||||
|
||||
e = await mkEngine(dbName);
|
||||
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
|
||||
const rows = await e.find('t', { table: 't' });
|
||||
expect(rows).toHaveLength(0);
|
||||
await e.close();
|
||||
});
|
||||
|
||||
test('WAL 恢复后截断:重启不再重复回放(checkpoint 后 WAL 空)', async () => {
|
||||
const dbName = `crash-wal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
let e = await mkEngine(dbName);
|
||||
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, v: { type: 'number' } } });
|
||||
await e.insert('t', [{ id: '1', v: 1 }, { id: '2', v: 2 }]);
|
||||
await e.dropTable('t');
|
||||
|
||||
// 第一次恢复:WAL 回放 + 截断
|
||||
e = await mkEngine(dbName);
|
||||
expect(await e.getTableNames()).not.toContain('t');
|
||||
|
||||
// 第二次恢复:WAL 已空,不再有任何回放副作用
|
||||
await e.close();
|
||||
e = await mkEngine(dbName);
|
||||
expect(await e.getTableNames()).not.toContain('t');
|
||||
await e.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P0-2: MemoryEngine update/delete 索引维护
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('[v0.3.3] P0-2: Memory 引擎索引维护', () => {
|
||||
test('update 修改唯一列后,重复值插入被拦截(unique 约束不被绕过)', async () => {
|
||||
const e = new MemoryEngine();
|
||||
await e.open('x', 1);
|
||||
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, email: { type: 'string', unique: true, index: true } } });
|
||||
await e.insert('t', [{ id: '1', email: 'a@x.com' }]);
|
||||
await e.update('t', { table: 't', where: { id: '1' } }, { email: 'b@x.com' });
|
||||
await expect(e.insert('t', [{ id: '2', email: 'b@x.com' }])).rejects.toThrow('Unique constraint');
|
||||
});
|
||||
|
||||
test('update 后按新值索引查询命中', async () => {
|
||||
const e = new MemoryEngine();
|
||||
await e.open('x', 1);
|
||||
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, email: { type: 'string', index: true } } });
|
||||
await e.insert('t', [{ id: '1', email: 'a@x.com' }]);
|
||||
await e.update('t', { table: 't', where: { id: '1' } }, { email: 'b@x.com' });
|
||||
const rows = await e.find('t', { table: 't', where: { email: 'b@x.com' } });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].id).toBe('1');
|
||||
});
|
||||
|
||||
test('update 到已存在的唯一值时抛 UNIQUE_VIOLATION', async () => {
|
||||
const e = new MemoryEngine();
|
||||
await e.open('x', 1);
|
||||
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, email: { type: 'string', unique: true, index: true } } });
|
||||
await e.insert('t', [{ id: '1', email: 'a@x.com' }, { id: '2', email: 'b@x.com' }]);
|
||||
await expect(e.update('t', { table: 't', where: { id: '1' } }, { email: 'b@x.com' })).rejects.toThrow('Unique constraint');
|
||||
});
|
||||
|
||||
test('delete 后索引清理:同值可重新插入且索引查询正确', async () => {
|
||||
const e = new MemoryEngine();
|
||||
await e.open('x', 1);
|
||||
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, email: { type: 'string', unique: true, index: true } } });
|
||||
await e.insert('t', [{ id: '1', email: 'a@x.com' }]);
|
||||
await e.delete('t', { table: 't', where: { id: '1' } });
|
||||
await e.insert('t', [{ id: '2', email: 'a@x.com' }]);
|
||||
const rows = await e.find('t', { table: 't', where: { email: 'a@x.com' } });
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].id).toBe('2');
|
||||
});
|
||||
|
||||
test('级联删除清理子表索引条目', async () => {
|
||||
const e = new MemoryEngine();
|
||||
await e.open('x', 1);
|
||||
await e.createTable({ name: 'users', columns: { id: { type: 'string', primaryKey: true } } });
|
||||
await e.createTable({ name: 'orders', columns: { id: { type: 'string', primaryKey: true }, user_id: { type: 'string', index: true, references: 'users.id', onDelete: 'CASCADE' } } });
|
||||
await e.insert('users', [{ id: 'u1' }]);
|
||||
await e.insert('orders', [{ id: 'o1', user_id: 'u1' }]);
|
||||
await e.delete('users', { table: 'users', where: { id: 'u1' } });
|
||||
// u1 已删,o1 级联删除 → 按 user_id 索引查询应为空
|
||||
const rows = await e.find('orders', { table: 'orders', where: { user_id: 'u1' } });
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P1-3: Aria 事务内读到自己写入的变更
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('[v0.3.3] P1-3: Aria 事务内读写一致', () => {
|
||||
test('事务内 insert 后 update 同一行生效', async () => {
|
||||
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||
await e.open('txn1', 1);
|
||||
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string' } } });
|
||||
await e.beginTransaction();
|
||||
await e.insert('t', [{ id: '1', name: 'Alice' }]);
|
||||
const n = await e.update('t', { table: 't', where: { id: '1' } }, { name: 'Bob' });
|
||||
await e.commitTransaction();
|
||||
const rows = await e.find('t', { table: 't' });
|
||||
expect(n).toBe(1);
|
||||
expect(rows[0].name).toBe('Bob');
|
||||
await e.close();
|
||||
});
|
||||
|
||||
test('事务内 insert 后 delete 该行生效', async () => {
|
||||
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||
await e.open('txn2', 1);
|
||||
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
|
||||
await e.beginTransaction();
|
||||
await e.insert('t', [{ id: '1' }, { id: '2' }]);
|
||||
const d = await e.delete('t', { table: 't', where: { id: '2' } });
|
||||
await e.commitTransaction();
|
||||
const rows = await e.find('t', { table: 't' });
|
||||
expect(d).toBe(1);
|
||||
expect(rows).toHaveLength(1);
|
||||
await e.close();
|
||||
});
|
||||
|
||||
test('rollback 后索引无残留(事务内写入的索引被重建清理)', async () => {
|
||||
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||
await e.open('txn3', 1);
|
||||
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string', index: true } } });
|
||||
await e.beginTransaction();
|
||||
await e.insert('t', [{ id: '1', name: 'Dave' }]);
|
||||
await e.rollbackTransaction();
|
||||
const rows = await e.find('t', { table: 't', where: { name: 'Dave' } });
|
||||
expect(rows).toHaveLength(0);
|
||||
await e.close();
|
||||
});
|
||||
|
||||
test('rollback 更新后索引恢复旧值', async () => {
|
||||
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||
await e.open('txn4', 1);
|
||||
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string', index: true } } });
|
||||
await e.insert('t', [{ id: '1', name: 'Bob' }]);
|
||||
await e.beginTransaction();
|
||||
await e.update('t', { table: 't', where: { id: '1' } }, { name: 'Eve' });
|
||||
await e.rollbackTransaction();
|
||||
const rows = await e.find('t', { table: 't', where: { name: 'Bob' } });
|
||||
expect(rows).toHaveLength(1);
|
||||
const stale = await e.find('t', { table: 't', where: { name: 'Eve' } });
|
||||
expect(stale).toHaveLength(0);
|
||||
await e.close();
|
||||
});
|
||||
|
||||
test('事务内 clear 生效且可提交', async () => {
|
||||
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||
await e.open('txn5', 1);
|
||||
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
|
||||
await e.insert('t', [{ id: '1' }, { id: '2' }]);
|
||||
await e.beginTransaction();
|
||||
await e.clear('t');
|
||||
await e.commitTransaction();
|
||||
expect(await e.count('t')).toBe(0);
|
||||
await e.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P1-5: WAL 字节数跟踪
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('[v0.3.3] P1-5: WAL 字节数跟踪', () => {
|
||||
test('full 模式 append 后 getBufferedBytes 反映实际字节数', async () => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
const wal = new WAL({
|
||||
append: async (d) => { chunks.push(d); },
|
||||
readAll: async () => { const t = chunks.reduce((s, c) => s + c.byteLength, 0); const out = new Uint8Array(t); let o = 0; for (const c of chunks) { out.set(c, o); o += c.byteLength; } return out; },
|
||||
truncate: async () => { chunks.length = 0; },
|
||||
exists: async () => chunks.length > 0,
|
||||
}, true, 'full');
|
||||
|
||||
await wal.append({ type: 1, txnId: 0, tableName: 't', key: '1', data: { a: 1 } } as never);
|
||||
const bytesAfterAppend = wal.getBufferedBytes();
|
||||
expect(bytesAfterAppend).toBeGreaterThan(0);
|
||||
|
||||
await wal.checkpoint();
|
||||
expect(wal.getBufferedBytes()).toBe(0);
|
||||
});
|
||||
|
||||
test('batch 模式 flush 后字节数保留(未 checkpoint 前)', async () => {
|
||||
const chunks: Uint8Array[] = [];
|
||||
const wal = new WAL({
|
||||
append: async (d) => { chunks.push(d); },
|
||||
readAll: async () => new Uint8Array(0),
|
||||
truncate: async () => { chunks.length = 0; },
|
||||
exists: async () => chunks.length > 0,
|
||||
}, true, 'batch');
|
||||
|
||||
await wal.append({ type: 1, txnId: 0, tableName: 't', key: '1', data: { a: 1 } } as never);
|
||||
await wal.flush();
|
||||
expect(wal.getBufferedBytes()).toBeGreaterThan(0); // 已写盘但未 checkpoint
|
||||
await wal.checkpoint();
|
||||
expect(wal.getBufferedBytes()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P1-6: Aria 主键列走主 LSM(无冗余二级索引)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('[v0.3.3] P1-6: Aria 主键查询优化', () => {
|
||||
test('PK $in 查询走主 LSM 返回正确行', async () => {
|
||||
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||
await e.open('pk1', 1);
|
||||
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, v: { type: 'number' } } });
|
||||
await e.insert('t', [{ id: '1', v: 10 }, { id: '2', v: 20 }, { id: '3', v: 30 }]);
|
||||
const rows = await e.find('t', { table: 't', where: { id: { $in: ['1', '3'] } } });
|
||||
expect(rows.map((r) => r.id).sort()).toEqual(['1', '3']);
|
||||
await e.close();
|
||||
});
|
||||
|
||||
test('PK 范围查询(字符串字典序)', async () => {
|
||||
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||
await e.open('pk2', 1);
|
||||
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, v: { type: 'number' } } });
|
||||
await e.insert('t', [{ id: 'a', v: 1 }, { id: 'b', v: 2 }, { id: 'c', v: 3 }]);
|
||||
const rows = await e.find('t', { table: 't', where: { id: { $gte: 'b' } } });
|
||||
expect(rows.map((r) => r.id)).toEqual(['b', 'c']);
|
||||
const lt = await e.find('t', { table: 't', where: { id: { $lt: 'b' } } });
|
||||
expect(lt.map((r) => r.id)).toEqual(['a']);
|
||||
await e.close();
|
||||
});
|
||||
|
||||
test('主键列不再创建独立二级索引(dropIndex 仍保护主键)', async () => {
|
||||
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||
await e.open('pk3', 1);
|
||||
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, name: { type: 'string', index: true } } });
|
||||
// 主键索引保护仍在
|
||||
await expect(e.dropIndex('t', 'id')).rejects.toThrow('Cannot drop primary key');
|
||||
// 二级索引(name)仍可正常使用
|
||||
await e.insert('t', [{ id: '1', name: 'x' }]);
|
||||
const rows = await e.find('t', { table: 't', where: { name: 'x' } });
|
||||
expect(rows).toHaveLength(1);
|
||||
await e.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P1-7: ORDER BY 别名
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('[v0.3.3] P1-7: ORDER BY 别名', () => {
|
||||
test('SELECT 别名可被 ORDER BY 引用', async () => {
|
||||
const db = new MetonaSqlark({ name: `alias-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
name: { type: 'string' },
|
||||
age: { type: 'number' },
|
||||
});
|
||||
await db.query("INSERT INTO users VALUES ('1', 'Alice', 30)");
|
||||
await db.query("INSERT INTO users VALUES ('2', 'Bob', 25)");
|
||||
await db.query("INSERT INTO users VALUES ('3', 'Carol', 35)");
|
||||
|
||||
const rows = await db.query('SELECT name AS n FROM users ORDER BY n DESC') as Record<string, unknown>[];
|
||||
expect(rows.map((r) => r.n)).toEqual(['Carol', 'Bob', 'Alice']);
|
||||
|
||||
const withLimit = await db.query('SELECT name AS n FROM users ORDER BY n ASC LIMIT 2') as Record<string, unknown>[];
|
||||
expect(withLimit.map((r) => r.n)).toEqual(['Alice', 'Bob']);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('GROUP BY 场景 ORDER BY 聚合别名', async () => {
|
||||
const db = new MetonaSqlark({ name: `alias2-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('emp', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
dept: { type: 'string' },
|
||||
salary: { type: 'number' },
|
||||
});
|
||||
await db.query("INSERT INTO emp VALUES ('1', 'eng', 100)");
|
||||
await db.query("INSERT INTO emp VALUES ('2', 'eng', 200)");
|
||||
await db.query("INSERT INTO emp VALUES ('3', 'ops', 300)");
|
||||
|
||||
const rows = await db.query('SELECT dept, COUNT(*) AS cnt FROM emp GROUP BY dept ORDER BY cnt DESC') as Record<string, unknown>[];
|
||||
expect(rows[0]).toEqual({ dept: 'eng', cnt: 2 });
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P1-8: SQL 字符串 '' 标准转义
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('[v0.3.3] P1-8: SQL 字符串转义', () => {
|
||||
test("'' 双引号转义为单个引号", async () => {
|
||||
const db = new MetonaSqlark({ name: `esc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('t', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
note: { type: 'string' },
|
||||
});
|
||||
await db.query("INSERT INTO t VALUES ('1', 'it''s a test')");
|
||||
const rows = await db.query('SELECT * FROM t') as Record<string, unknown>[];
|
||||
expect(rows[0].note).toBe("it's a test");
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('反斜杠转义仍兼容', () => {
|
||||
const tokens = tokenize("SELECT 'a\\'b'");
|
||||
expect(tokens[1].value).toBe("a'b");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// P1-9: Savepoint 回滚后 MVCC 一致
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('[v0.3.3] P1-9: Savepoint + MVCC 一致性', () => {
|
||||
test('rollbackToSavepoint 后提交,数据为 savepoint 时状态', async () => {
|
||||
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||
await e.open('sp1', 1);
|
||||
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true }, v: { type: 'number' } } });
|
||||
await e.insert('t', [{ id: '1', v: 1 }]);
|
||||
await e.beginTransaction();
|
||||
await e.insert('t', [{ id: '2', v: 2 }]);
|
||||
await e.savepoint('sp');
|
||||
await e.insert('t', [{ id: '3', v: 3 }]);
|
||||
await e.rollbackToSavepoint('sp');
|
||||
await e.commitTransaction();
|
||||
const rows = await e.find('t', { table: 't' });
|
||||
expect(rows.map((r) => r.id).sort()).toEqual(['1', '2']);
|
||||
await e.close();
|
||||
});
|
||||
|
||||
test('savepoint 回滚后事务仍可继续写入并提交', async () => {
|
||||
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||
await e.open('sp2', 1);
|
||||
await e.createTable({ name: 't', columns: { id: { type: 'string', primaryKey: true } } });
|
||||
await e.beginTransaction();
|
||||
await e.savepoint('sp');
|
||||
await e.insert('t', [{ id: '1' }]);
|
||||
await e.rollbackToSavepoint('sp');
|
||||
await e.insert('t', [{ id: '2' }]);
|
||||
await e.commitTransaction();
|
||||
const rows = await e.find('t', { table: 't' });
|
||||
expect(rows.map((r) => r.id)).toEqual(['2']);
|
||||
await e.close();
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 端到端冒烟
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('[v0.3.3] 端到端', () => {
|
||||
test('全部修复点可共存于 MetonaSqlark API', async () => {
|
||||
expect(VERSION).toBe('0.4.1');
|
||||
const db = new MetonaSqlark({ name: `e2e-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('users', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
email: { type: 'string', unique: true, index: true },
|
||||
age: { type: 'number' },
|
||||
});
|
||||
await db.query("INSERT INTO users VALUES ('1', 'a@x.com', 20)");
|
||||
await db.query("INSERT INTO users VALUES ('2', 'b@x.com', 30)");
|
||||
// 别名排序 + 唯一约束组合
|
||||
const rows = await db.query('SELECT email AS e FROM users ORDER BY e DESC') as Record<string, unknown>[];
|
||||
expect(rows.map((r) => r.e)).toEqual(['b@x.com', 'a@x.com']);
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
/**
|
||||
* v0.4.0 功能扩展测试
|
||||
* - B-1: 流式查询 queryStream / findStream
|
||||
* - B-2: 多列 ON 哈希连接
|
||||
* - B-3: FROM 子查询(派生表)
|
||||
* - B-4: COUNT(DISTINCT) + NULLS FIRST/LAST
|
||||
*/
|
||||
|
||||
import 'fake-indexeddb/auto';
|
||||
import { MetonaSqlark } from '../src/core';
|
||||
import { AriaEngine } from '../src/engine/aria/index';
|
||||
|
||||
const uniqueName = (prefix: string): string =>
|
||||
`${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
describe('[v0.4.0] B-1: 流式查询', () => {
|
||||
test('Aria findStream 逐行回调 + limit 生效', async () => {
|
||||
const e = new AriaEngine({ storageBackend: 'memory', walSyncMode: 'full' });
|
||||
await e.open('stream1', 1);
|
||||
await e.createTable({ name: 'logs', columns: { id: { type: 'string', primaryKey: true }, level: { type: 'string' } } });
|
||||
const rows: Record<string, unknown>[] = [];
|
||||
for (let i = 0; i < 5000; i++) rows.push({ id: `${i}`, level: i % 2 ? 'error' : 'info' });
|
||||
await e.insert('logs', rows);
|
||||
|
||||
let count = 0;
|
||||
let sum = 0;
|
||||
const n = await e.findStream('logs', { table: 'logs', where: { level: 'error' }, limit: 100 }, (r) => {
|
||||
count++;
|
||||
sum += Number(r.id);
|
||||
});
|
||||
expect(n).toBe(100);
|
||||
expect(count).toBe(100);
|
||||
expect(sum).toBeGreaterThan(0);
|
||||
await e.close();
|
||||
});
|
||||
|
||||
test('db.queryStream 端到端(WHERE + 投影)', async () => {
|
||||
const db = new MetonaSqlark({ name: uniqueName('qs'), mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, age: { type: 'number' } });
|
||||
await db.query("INSERT INTO users VALUES ('1', 20), ('2', 30), ('3', 40), ('4', 30)");
|
||||
const seen: Record<string, unknown>[] = [];
|
||||
const total = await db.queryStream('SELECT id FROM users WHERE age >= 30', (row) => seen.push(row));
|
||||
expect(total).toBe(3);
|
||||
expect(seen.map((r) => r.id).sort()).toEqual(['2', '3', '4']);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('queryStream 对 async 回调回退物化(不吞 Promise)', async () => {
|
||||
const db = new MetonaSqlark({ name: uniqueName('qa'), mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
|
||||
await db.query("INSERT INTO t VALUES ('1'), ('2')");
|
||||
const seen: string[] = [];
|
||||
const total = await db.queryStream('SELECT * FROM t', async (row) => {
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
seen.push(String(row.id));
|
||||
});
|
||||
expect(total).toBe(2);
|
||||
expect(seen.sort()).toEqual(['1', '2']);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('queryStream 不支持非 SELECT 语句', async () => {
|
||||
const db = new MetonaSqlark({ name: uniqueName('qi'), mode: 'memory' });
|
||||
await db.init();
|
||||
await expect(db.queryStream('INSERT INTO x VALUES (1)', () => {})).rejects.toThrow('only supports SELECT');
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('db.table().stream() 逐行回调', async () => {
|
||||
const db = new MetonaSqlark({ name: uniqueName('qt'), mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
|
||||
await db.query("INSERT INTO t VALUES ('1', 10), ('2', 20), ('3', 30)");
|
||||
const seen: number[] = [];
|
||||
const total = await db.table('t').stream((row) => seen.push(Number(row.v)), { where: { v: { $gte: 20 } } });
|
||||
expect(total).toBe(2);
|
||||
expect(seen.sort((a, b) => a - b)).toEqual([20, 30]);
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('[v0.4.0] B-2: 多列 ON 哈希连接', () => {
|
||||
test('复合键 INNER JOIN 结果正确', async () => {
|
||||
const db = new MetonaSqlark({ name: uniqueName('hj'), mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('a', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
tenant: { type: 'string', index: true },
|
||||
key: { type: 'string' },
|
||||
});
|
||||
await db.defineTable('b', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
tenant: { type: 'string', index: true },
|
||||
key: { type: 'string' },
|
||||
val: { type: 'number' },
|
||||
});
|
||||
await db.query("INSERT INTO a VALUES ('a1', 't1', 'k1'), ('a2', 't2', 'k2'), ('a3', 't1', 'k9')");
|
||||
await db.query("INSERT INTO b VALUES ('b1', 't1', 'k1', 100), ('b2', 't1', 'k2', 200), ('b3', 't2', 'k2', 300)");
|
||||
|
||||
// tenant + key 复合等值连接
|
||||
const rows = await db.query(
|
||||
"SELECT a.id AS aid, b.val FROM a INNER JOIN b ON a.tenant = b.tenant AND a.key = b.key",
|
||||
) as Record<string, unknown>[];
|
||||
expect(rows.length).toBe(2);
|
||||
const byAid = Object.fromEntries(rows.map((r) => [r.aid, r['b.val']]));
|
||||
expect(byAid['a1']).toBe(100); // t1/k1 匹配 b1
|
||||
expect(byAid['a2']).toBe(300); // t2/k2 匹配 b3(t1/k2 的 b2 因 tenant 不同被排除)
|
||||
expect(byAid['a3']).toBeUndefined(); // t1/k9 无匹配
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('复合键 LEFT JOIN 无匹配行置 null', async () => {
|
||||
const db = new MetonaSqlark({ name: uniqueName('hl'), mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('a', { id: { type: 'string', primaryKey: true }, k1: { type: 'string', index: true }, k2: { type: 'string' } });
|
||||
await db.defineTable('b', { id: { type: 'string', primaryKey: true }, k1: { type: 'string', index: true }, k2: { type: 'string' }, v: { type: 'number' } });
|
||||
await db.query("INSERT INTO a VALUES ('a1', 'x', 'y'), ('a2', 'x', 'z')");
|
||||
await db.query("INSERT INTO b VALUES ('b1', 'x', 'y', 5)");
|
||||
const rows = await db.query(
|
||||
"SELECT a.id AS aid, b.v FROM a LEFT JOIN b ON a.k1 = b.k1 AND a.k2 = b.k2",
|
||||
) as Record<string, unknown>[];
|
||||
expect(rows.length).toBe(2);
|
||||
expect(rows.find((r) => r.aid === 'a1')?.['b.v']).toBe(5);
|
||||
expect(rows.find((r) => r.aid === 'a2')?.['b.v']).toBeNull();
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('[v0.4.0] B-3: FROM 子查询(派生表)', () => {
|
||||
test('FROM (SELECT ...) 派生表 + WHERE + ORDER BY', async () => {
|
||||
const db = new MetonaSqlark({ name: uniqueName('sub'), mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('emp', {
|
||||
id: { type: 'string', primaryKey: true },
|
||||
dept: { type: 'string' },
|
||||
salary: { type: 'number' },
|
||||
});
|
||||
await db.query("INSERT INTO emp VALUES ('1', 'eng', 100), ('2', 'eng', 300), ('3', 'ops', 200)");
|
||||
|
||||
const rows = await db.query(
|
||||
"SELECT dept, total FROM (SELECT dept, SUM(salary) AS total FROM emp GROUP BY dept) AS t WHERE total > 150 ORDER BY total DESC",
|
||||
) as Record<string, unknown>[];
|
||||
expect(rows).toEqual([{ dept: 'eng', total: 400 }, { dept: 'ops', total: 200 }]);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('派生表无别名也可用', async () => {
|
||||
const db = new MetonaSqlark({ name: uniqueName('sub2'), mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
|
||||
await db.query("INSERT INTO t VALUES ('1', 1), ('2', 2)");
|
||||
const rows = await db.query("SELECT v FROM (SELECT * FROM t WHERE v > 1) WHERE v < 10") as Record<string, unknown>[];
|
||||
expect(rows).toEqual([{ v: 2 }]);
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe('[v0.4.0] B-4: COUNT(DISTINCT) + NULLS FIRST/LAST', () => {
|
||||
test('COUNT(DISTINCT col)', async () => {
|
||||
const db = new MetonaSqlark({ name: uniqueName('cd'), mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, dept: { type: 'string' } });
|
||||
await db.query("INSERT INTO t VALUES ('1', 'a'), ('2', 'b'), ('3', 'a'), ('4', 'c')");
|
||||
const rows = await db.query('SELECT COUNT(DISTINCT dept) AS n FROM t') as Record<string, unknown>[];
|
||||
expect(rows[0].n).toBe(3);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('SUM(DISTINCT col) 与 GROUP BY 组合', async () => {
|
||||
const db = new MetonaSqlark({ name: uniqueName('sd'), mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, g: { type: 'string' }, v: { type: 'number' } });
|
||||
await db.query("INSERT INTO t VALUES ('1', 'x', 10), ('2', 'x', 10), ('3', 'y', 20)");
|
||||
const rows = await db.query('SELECT g, SUM(DISTINCT v) AS s FROM t GROUP BY g ORDER BY g') as Record<string, unknown>[];
|
||||
expect(rows).toEqual([{ g: 'x', s: 10 }, { g: 'y', s: 20 }]);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('NULLS FIRST 排序', async () => {
|
||||
const db = new MetonaSqlark({ name: uniqueName('nf'), mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
|
||||
await db.query("INSERT INTO t VALUES ('1', 10), ('2', NULL), ('3', 20)");
|
||||
const rows = await db.query('SELECT v FROM t ORDER BY v ASC NULLS FIRST') as Record<string, unknown>[];
|
||||
expect(rows).toEqual([{ v: null }, { v: 10 }, { v: 20 }]);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('NULLS LAST 排序(降序时 NULL 排最后)', async () => {
|
||||
const db = new MetonaSqlark({ name: uniqueName('nl'), mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('t', { id: { type: 'string', primaryKey: true }, v: { type: 'number' } });
|
||||
await db.query("INSERT INTO t VALUES ('1', 10), ('2', NULL), ('3', 20)");
|
||||
const rows = await db.query('SELECT v FROM t ORDER BY v DESC NULLS LAST') as Record<string, unknown>[];
|
||||
expect(rows).toEqual([{ v: 20 }, { v: 10 }, { v: null }]);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('字符串常量列 + \'\' 转义(无 FROM 查询)', async () => {
|
||||
const db = new MetonaSqlark({ name: uniqueName('lit'), mode: 'memory' });
|
||||
await db.init();
|
||||
const rows = await db.query("SELECT 'it''s a test' AS escaped") as Record<string, unknown>[];
|
||||
expect(rows).toEqual([{ escaped: "it's a test" }]);
|
||||
const mixed = await db.query("SELECT 'hello' AS greeting, 'world' AS subject") as Record<string, unknown>[];
|
||||
expect(mixed).toEqual([{ greeting: 'hello', subject: 'world' }]);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('ORDER BY / GROUP BY 支持表前缀列(u.name / o.amount)', async () => {
|
||||
const db = new MetonaSqlark({ name: uniqueName('prefix'), mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string' }, age: { type: 'number' } });
|
||||
await db.defineTable('orders', { id: { type: 'string', primaryKey: true }, user_id: { type: 'string' }, amount: { type: 'number' } });
|
||||
await db.query("INSERT INTO users VALUES ('1', 'Alice', 30), ('2', 'Bob', 25)");
|
||||
await db.query("INSERT INTO orders VALUES ('o1', '1', 1200), ('o2', '2', 50)");
|
||||
|
||||
// JOIN + ORDER BY 表前缀(演示页报错场景)
|
||||
const join = await db.query(
|
||||
'SELECT u.name, o.amount FROM users u INNER JOIN orders o ON u.id = o.user_id ORDER BY o.amount DESC',
|
||||
) as Record<string, unknown>[];
|
||||
expect(join.map((r) => r['u.name'])).toEqual(['Alice', 'Bob']);
|
||||
|
||||
// JOIN + GROUP BY 表前缀
|
||||
const grouped = await db.query(
|
||||
'SELECT u.name, SUM(o.amount) AS total FROM users u INNER JOIN orders o ON u.id = o.user_id GROUP BY u.name',
|
||||
) as Record<string, unknown>[];
|
||||
expect(grouped.map((r) => r.total).sort((a, b) => Number(b) - Number(a))).toEqual([1200, 50]);
|
||||
|
||||
// 非 JOIN + ORDER BY / GROUP BY 表前缀
|
||||
const plain = await db.query('SELECT name FROM users ORDER BY users.age DESC') as Record<string, unknown>[];
|
||||
expect(plain.map((r) => r.name)).toEqual(['Alice', 'Bob']);
|
||||
const byAge = await db.query('SELECT users.age, COUNT(*) AS n FROM users GROUP BY users.age') as Record<string, unknown>[];
|
||||
expect(byAge.length).toBe(2);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('INSERT ... SELECT 按源表列顺序映射(缺列不填,不报类型错)', async () => {
|
||||
const db = new MetonaSqlark({ name: uniqueName('insel'), mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string', required: true }, email: { type: 'string' }, age: { type: 'number', default: 0 } });
|
||||
await db.table('users').insertMany([{ id: '1', name: 'Alice', email: 'a@x.com', age: 30 }]);
|
||||
// alter 预设:ADD 列 → 显式列名插入(无 email)→ DROP 列,产生行键缺 email 的行
|
||||
await db.query('ALTER TABLE users ADD COLUMN phone STRING');
|
||||
await db.query("INSERT INTO users (id, name, age, phone) VALUES ('6', 'Frank', 33, '123')");
|
||||
await db.query('ALTER TABLE users DROP COLUMN phone');
|
||||
|
||||
await db.query('CREATE TABLE users_backup (id STRING PRIMARY KEY, name STRING, email STRING, age NUMBER)');
|
||||
await db.query('INSERT INTO users_backup SELECT * FROM users');
|
||||
const rows = await db.query('SELECT * FROM users_backup ORDER BY id') as Record<string, unknown>[];
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows[0]).toEqual({ id: '1', name: 'Alice', email: 'a@x.com', age: 30 });
|
||||
expect(rows[1].id).toBe('6');
|
||||
expect(rows[1].name).toBe('Frank');
|
||||
expect(rows[1].age).toBe(33);
|
||||
|
||||
// SELECT 指定列映射
|
||||
await db.query('CREATE TABLE names_only (id STRING PRIMARY KEY, name STRING)');
|
||||
await db.query('INSERT INTO names_only SELECT id, name FROM users');
|
||||
const names = await db.query('SELECT * FROM names_only') as Record<string, unknown>[];
|
||||
expect(names.map((r) => r.name).sort()).toEqual(['Alice', 'Frank']);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('关联 EXISTS / NOT EXISTS(SELECT 列仅含 name 时绑定主键仍生效)', async () => {
|
||||
const db = new MetonaSqlark({ name: uniqueName('exists'), mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string' }, age: { type: 'number' } });
|
||||
await db.defineTable('orders', { id: { type: 'string', primaryKey: true }, user_id: { type: 'string' }, amount: { type: 'number' } });
|
||||
await db.query("INSERT INTO users VALUES ('1', 'Alice', 30), ('2', 'Bob', 25), ('3', 'Eve', 22)");
|
||||
await db.query("INSERT INTO orders VALUES ('o1', '1', 100), ('o2', '1', 200), ('o3', '2', 50)");
|
||||
|
||||
// SELECT 只投影 name(不含 id),EXISTS 绑定 u.id 必须仍工作
|
||||
const withOrders = await db.query(
|
||||
'SELECT u.name FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)',
|
||||
) as Record<string, unknown>[];
|
||||
expect(withOrders.map((r) => r.name).sort()).toEqual(['Alice', 'Bob']);
|
||||
|
||||
const without = await db.query(
|
||||
'SELECT u.name FROM users u WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)',
|
||||
) as Record<string, unknown>[];
|
||||
expect(without.map((r) => r.name)).toEqual(['Eve']);
|
||||
|
||||
// SELECT 列带表前缀 + EXISTS 组合
|
||||
const prefixed = await db.query(
|
||||
'SELECT u.name FROM users u WHERE u.age > 21 AND EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id)',
|
||||
) as Record<string, unknown>[];
|
||||
expect(prefixed.map((r) => r.name).sort()).toEqual(['Alice', 'Bob']);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('HAVING 引用聚合表达式 + 标量子查询', async () => {
|
||||
const db = new MetonaSqlark({ name: uniqueName('having'), mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { 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 ('1', 'Alice'), ('2', 'Bob'), ('3', 'Charlie')");
|
||||
await db.query("INSERT INTO orders VALUES ('o1', '1', 1200), ('o2', '1', 50), ('o3', '2', 150), ('o4', '3', 400), ('o5', '3', 20)");
|
||||
|
||||
// HAVING SUM(...) > (SELECT AVG(...)):均值 364,Alice 1250 / Charlie 420 达标
|
||||
const rows = await db.query(
|
||||
'SELECT u.name, SUM(o.amount) AS spent FROM users u INNER JOIN orders o ON u.id = o.user_id GROUP BY u.name HAVING SUM(o.amount) > (SELECT AVG(amount) FROM orders)',
|
||||
) as Record<string, unknown>[];
|
||||
expect(rows.map((r) => [r['u.name'], r.spent])).toEqual([['Alice', 1250], ['Charlie', 420]]);
|
||||
|
||||
// HAVING 引用别名也生效
|
||||
const byAlias = await db.query(
|
||||
'SELECT u.name, SUM(o.amount) AS spent FROM users u INNER JOIN orders o ON u.id = o.user_id GROUP BY u.name HAVING spent > 400',
|
||||
) as Record<string, unknown>[];
|
||||
expect(byAlias.map((r) => r['u.name']).sort()).toEqual(['Alice', 'Charlie']);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('JOIN 主表 WHERE 条件下推走索引 + DROP INDEX 报错', async () => {
|
||||
const db = new MetonaSqlark({ name: uniqueName('idx'), mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string' } });
|
||||
await db.defineTable('orders', { id: { type: 'string', primaryKey: true }, user_id: { type: 'string' }, product: { type: 'string' } });
|
||||
await db.query("INSERT INTO users VALUES ('1', 'Alice'), ('2', 'Bob')");
|
||||
await db.query("INSERT INTO orders VALUES ('o1', '1', 'Laptop'), ('o2', '1', 'Mouse'), ('o3', '2', 'Keyboard')");
|
||||
|
||||
await db.query('CREATE INDEX idx_orders_user ON orders (user_id)');
|
||||
|
||||
// WHERE 主表前缀条件下推:引擎收到的 orders 查询 where 为 { user_id: { $eq: '1' } }
|
||||
const eng = db.getEngine() as { find: (...a: unknown[]) => Promise<unknown> };
|
||||
const origFind = eng.find.bind(eng);
|
||||
let pushed: unknown = null;
|
||||
eng.find = async (t: string, q: { where?: unknown }) => {
|
||||
if (t === 'orders' && q?.where) pushed = q.where;
|
||||
return origFind(t, q);
|
||||
};
|
||||
const rows = await db.query(
|
||||
"SELECT u.name, o.product FROM orders o JOIN users u ON u.id = o.user_id WHERE o.user_id = '1'",
|
||||
) as Record<string, unknown>[];
|
||||
expect(rows.map((r) => [r['u.name'], r['o.product']])).toEqual([['Alice', 'Laptop'], ['Alice', 'Mouse']]);
|
||||
expect(pushed).toEqual({ user_id: { $eq: '1' } });
|
||||
|
||||
// DROP 存在的索引成功;再次 DROP(列已无索引)报 INDEX_NOT_FOUND
|
||||
await db.query('DROP INDEX idx_orders_user ON orders (user_id)');
|
||||
await expect(db.query('DROP INDEX idx_nonexist ON orders (user_id)')).rejects.toThrow('Index on column');
|
||||
|
||||
// DROP 后回退全表扫描,结果不变
|
||||
const after = await db.query("SELECT product FROM orders WHERE user_id = '2'") as Record<string, unknown>[];
|
||||
expect(after.map((r) => r.product)).toEqual(['Keyboard']);
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('裸布尔列条件:WHERE done / CASE WHEN done(真值判断)', async () => {
|
||||
const db = new MetonaSqlark({ name: uniqueName('bool'), mode: 'memory' });
|
||||
await db.init();
|
||||
await db.defineTable('tasks', { id: { type: 'string', primaryKey: true }, title: { type: 'string' }, done: { type: 'boolean', default: false } });
|
||||
await db.query("INSERT INTO tasks VALUES ('t1', 'A', false), ('t2', 'B', true), ('t3', 'C', false)");
|
||||
|
||||
// WHERE 裸列
|
||||
const done = await db.query('SELECT id FROM tasks WHERE done') as Record<string, unknown>[];
|
||||
expect(done.map((r) => r.id)).toEqual(['t2']);
|
||||
|
||||
// CASE WHEN 裸列(演示页 aria 预设)
|
||||
const labeled = await db.query(
|
||||
"SELECT title, CASE WHEN done THEN 'done' ELSE 'pending' END AS status FROM tasks",
|
||||
) as Record<string, unknown>[];
|
||||
expect(labeled.find((r) => r.title === 'B')?.status).toBe('done');
|
||||
expect(labeled.find((r) => r.title === 'A')?.status).toBe('pending');
|
||||
await db.close();
|
||||
});
|
||||
|
||||
test('Aria $in 查询去重(IN 子查询含重复值不返回重复行)', async () => {
|
||||
const db = new MetonaSqlark({ name: uniqueName('indup'), mode: 'aria', diskEngine: 'indexeddb' });
|
||||
await db.init();
|
||||
await db.getEngine().clearAll();
|
||||
await db.defineTable('users', { id: { type: 'string', primaryKey: true }, name: { type: 'string' } });
|
||||
await db.defineTable('orders', { id: { type: 'string', primaryKey: true }, user_id: { type: 'string' } });
|
||||
await db.query("INSERT INTO users VALUES ('1', 'Alice'), ('2', 'Bob')");
|
||||
await db.query("INSERT INTO orders VALUES ('o1', '1'), ('o2', '1')");
|
||||
|
||||
const rows = await db.query('SELECT name FROM users WHERE id IN (SELECT user_id FROM orders)') as Record<string, unknown>[];
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].name).toBe('Alice');
|
||||
await db.close();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user