Files
MetonaSqlark/tests/v062-fixes.test.ts
thzxx 4ab04df882 fix(A15): 三值逻辑求值器统一 —— 消除 WHERE 的第二套语义(静默错值根治)
背景(PLAN-v0.7.5.md 根因 1/7、缺陷 A15):
项目里 `matchWhere`(布尔版,自带 matchField)与三值求值器并存。同一条 SQL 的
语义取决于走哪个函数,实测三类静默错值:
  - `WHERE n = NULL` 命中 NULL 行、`WHERE n != NULL` 返回所有非 NULL 行;
  - `WHERE s NOT LIKE 'x'` 会把 NULL 行判真(布尔取反);
  - `WHERE n NOT BETWEEN 1 AND 2` 恒空集 —— parser 生成的字段级
    `{ n: { $or: [ {$lt:1}, {$gt:2} ] } }` 递归进了 where 子句级求值器,
    子项 `{ $lt: 1 }` 被当成"查询列 `$lt`" → 每行 UNKNOWN。

根治方式(不是打补丁,而是取消第二套实现):
1. where-matcher.ts 重写为**唯一一个递归求值器**,同时理解 where 子句级
   (键是列名/逻辑连接词)与操作符级(键是 `$gt` …),位置由上下文承载而非
   由另一个函数承载;`matchWhere` 退化为"三值结果是否恰为 TRUE"。
   行上下文随求值上下文下传,`$col` 在任意嵌套深度都能解析。
2. parser:`IS NULL` / `IS NOT NULL` 生成 `$isNull` / `$isNotNull` **谓词**
   (此前与 `= NULL` / `!= NULL` 共用 `$eq: null` / `$ne: null`,两者语义无法区分);
   `BETWEEN` 生成真正的范围条件(此前把同一对象同时当操作符对象与操作数);
   `NOT BETWEEN` 展开为 `$or: [{$lt}, {$gt}]`。
3. executor 新增 enginePreFilter:逐行求值谓词(`$col` / `$exists` / CASE 键)
   必须整体移出引擎层 —— 引擎无外层行上下文,会把它们判 UNKNOWN 并把**所有行**
   过滤掉,逐行求值再正确也无行可算。粒度按连接词决定:`$and` 成员可单独移除,
   `$or`/`$not` 成员一移除就改变结果集(漏行/多行),故整条下推放弃。

契约变更(旧测试编码了错误语义,已按 SQL 标准改正并注明理由):
  - a) `{ $eq: null }` 不再命中 NULL 行(`= NULL` 恒 UNKNOWN)→ 用 `$isNull`;
  - b) `IN` 列表含 NULL:`x IN (NULL, 'a')` 只命中 'a'(`null = NULL` 为 UNKNOWN),
       未命中的行仍因 UNKNOWN 不保留。
  - c) 引擎层 `$in: [null, ...]` 与 `$eq: null` 的断言同步修正。

验证:
  - 新增 tests/v080-sql-three-valued.test.ts:26 条 SQL 语义矩阵 × 4 引擎
    (memory/disk/hybrid/aria)+ UPDATE/DELETE 写路径,共 104 断言;
  - 全量 83 套件 / 1458 测试通过(含 Aria 生产负载 10 万行);
  - typecheck(src+tests) 与 lint 零错误。
2026-09-14 23:22:26 +08:00

429 lines
18 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* v0.6.2 修复回归测试 — 深度审计 P0/P1 修复锁定
*
* 覆盖:
* - P0: KVStoreEngine 数值主键 update 后行被误删(重启丢数据)
* - P0: Memory/Aria update 主键变更撞已有主键静默覆盖
* - P1: Aria 二级索引范围查询边界算法(小数/字符串漏数据)
* - P1: Aria 索引列 IS NULL 返回空
* - P1: Aria unique 约束未强制
* - P2: EXPLAIN 写语句产生真实副作用
* - 索引旧值残留(非主键 update 不清理旧索引条目)
*/
import { MetonaSqlark } from '../src/core';
import { MemoryEngine } from '../src/engine/memory';
import { KVStoreEngine } from '../src/engine/kvstore_engine';
import { AriaEngine } from '../src/engine/aria/index';
import { createSchema } from '../src/table/schema';
function uniqueDB(): string {
return `v062-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
/** 断言 Promise 以指定错误码拒绝 */
async function expectCode(promise: Promise<unknown>, code: string): Promise<void> {
try {
await promise;
} catch (e) {
expect((e as { code?: string }).code).toBe(code);
return;
}
throw new Error(`Expected rejection with code "${code}", but promise resolved`);
}
describe('v0.6.2 — 数值主键 update 数据丢失(KVStoreEngine', () => {
it('数值主键 update 后重开行不丢失', async () => {
const name = uniqueDB();
const eng = new KVStoreEngine();
await eng.open(name, 1);
await eng.createTable(createSchema('t', {
id: { type: 'number', primaryKey: true },
v: { type: 'string' },
}));
await eng.insert('t', [{ id: 1, v: 'a' }, { id: 2, v: 'b' }]);
await eng.update('t', { table: 't', where: { id: 1 } }, { v: 'A' });
await eng.close();
const eng2 = new KVStoreEngine();
await eng2.open(name, 1);
const rows = await eng2.find('t', { table: 't' });
expect(rows).toHaveLength(2);
const row1 = rows.find((r) => r.id === 1);
expect(row1).toBeDefined();
expect(row1!.v).toBe('A');
await eng2.close();
});
it('数值主键 delete 后重开不残留', async () => {
const name = uniqueDB();
const eng = new KVStoreEngine();
await eng.open(name, 1);
await eng.createTable(createSchema('t', {
id: { type: 'number', primaryKey: true },
}));
await eng.insert('t', [{ id: 1 }, { id: 2 }]);
await eng.delete('t', { table: 't', where: { id: 1 } });
await eng.close();
const eng2 = new KVStoreEngine();
await eng2.open(name, 1);
const rows = await eng2.find('t', { table: 't' });
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe(2);
await eng2.close();
});
it('高层 APIdisk 模式)数值主键 update 持久化正确', async () => {
const db = new MetonaSqlark({ name: uniqueDB(), mode: 'disk' });
await db.init();
await db.defineTable('t', {
id: { type: 'number', primaryKey: true },
name: { type: 'string' },
});
await db.query('INSERT INTO t VALUES (1, \'a\'), (2, \'b\')');
await db.query('UPDATE t SET name = \'A\' WHERE id = 1');
await db.close();
const db2 = new MetonaSqlark({ name: db.name, mode: 'disk' });
await db2.init();
const rows = await db2.query('SELECT * FROM t') as Record<string, unknown>[];
expect(rows).toHaveLength(2);
expect(rows.find((r) => r.id === 1)!.name).toBe('A');
await db2.close();
});
});
describe('v0.6.2 — update 主键撞已有主键(DUPLICATE_KEY', () => {
it('MemoryEngine 主键碰撞更新抛 DUPLICATE_KEY,数据不丢', async () => {
const eng = new MemoryEngine();
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
await eng.insert('t', [{ id: '1' }, { id: '2' }]);
await expectCode(eng.update('t', { table: 't', where: { id: '1' } }, { id: '2' }), 'DUPLICATE_KEY');
const rows = await eng.find('t', { table: 't' });
expect(rows).toHaveLength(2);
});
it('AriaEngine 主键碰撞更新抛 DUPLICATE_KEY,数据不丢', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
await eng.insert('t', [{ id: '1' }, { id: '2' }]);
await expectCode(eng.update('t', { table: 't', where: { id: '1' } }, { id: '2' }), 'DUPLICATE_KEY');
const rows = await eng.find('t', { table: 't' });
expect(rows).toHaveLength(2);
await eng.close();
});
it('事务内主键碰撞更新抛 DUPLICATE_KEY', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', { id: { type: 'string', primaryKey: true } }));
await eng.insert('t', [{ id: '1' }, { id: '2' }]);
await eng.beginTransaction();
await expectCode(eng.update('t', { table: 't', where: { id: '1' } }, { id: '2' }), 'DUPLICATE_KEY');
await eng.rollbackTransaction();
const rows = await eng.find('t', { table: 't' });
expect(rows).toHaveLength(2);
await eng.close();
});
});
describe('v0.6.2 — Aria 二级索引范围查询边界', () => {
async function makeEngine(): Promise<AriaEngine> {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
score: { type: 'number', index: true },
tag: { type: 'string', index: true },
}));
return eng;
}
it('小数数值范围查询($gt/$lt)不漏行', async () => {
const eng = await makeEngine();
await eng.insert('t', [
{ id: '1', score: 1.5, tag: 'a' }, { id: '2', score: 2.5, tag: 'b' },
{ id: '3', score: 3.5, tag: 'c' }, { id: '4', score: 2, tag: 'd' },
]);
const gt = await eng.find('t', { table: 't', where: { score: { $gt: 2 } } });
expect(gt.map((r) => r.id).sort()).toEqual(['2', '3']);
const lt = await eng.find('t', { table: 't', where: { score: { $lt: 2 } } });
expect(lt.map((r) => r.id).sort()).toEqual(['1']);
const gte = await eng.find('t', { table: 't', where: { score: { $gte: 2 } } });
expect(gte.map((r) => r.id).sort()).toEqual(['2', '3', '4']);
const lte = await eng.find('t', { table: 't', where: { score: { $lte: 2.5 } } });
expect(lte.map((r) => r.id).sort()).toEqual(['1', '2', '4']);
await eng.close();
});
it('字符串范围查询(小写/大写/数字开头)不漏行', async () => {
const eng = await makeEngine();
await eng.insert('t', [
{ id: '1', score: 1, tag: 'apple' }, { id: '2', score: 2, tag: 'Banana' },
{ id: '3', score: 3, tag: 'cherry' }, { id: '4', score: 4, tag: '1start' },
{ id: '5', score: 5, tag: 'Zebra' },
]);
const gt = await eng.find('t', { table: 't', where: { tag: { $gt: 'apple' } } });
// JS 字符串比较:'Banana' > 'apple'(大写 < 小写)→ 只有 cherry
expect(gt.map((r) => r.id).sort()).toEqual(['3']);
const lt = await eng.find('t', { table: 't', where: { tag: { $lt: 'Banana' } } });
expect(lt.map((r) => r.id).sort()).toEqual(['4']);
const gteDigit = await eng.find('t', { table: 't', where: { tag: { $gte: '1start' } } });
// JS 字符串比较:数字开头最小,全部字母值都 >= '1start'
expect(gteDigit.map((r) => r.id).sort()).toEqual(['1', '2', '3', '4', '5']);
await eng.close();
});
it('整数范围查询结果与全表扫描一致(flush 前后)', async () => {
const eng = await makeEngine();
await eng.insert('t', Array.from({ length: 50 }, (_, i) => ({ id: `${i}`, score: i * 10, tag: `t${i % 5}` })));
const byIndex = await eng.find('t', { table: 't', where: { score: { $gt: 200, $lte: 350 } } });
const fullScan = (await eng.find('t', { table: 't' }))
.filter((r) => (r.score as number) > 200 && (r.score as number) <= 350);
expect(byIndex.map((r) => r.id).sort()).toEqual(fullScan.map((r) => r.id).sort());
// flush 后(索引进 SSTable)结果一致
await (eng as any).lsm.flush();
await (eng as any).secondaryIndexes.get('t:idx:score').flush();
const after = await eng.find('t', { table: 't', where: { score: { $gt: 200, $lte: 350 } } });
expect(after.map((r) => r.id).sort()).toEqual(fullScan.map((r) => r.id).sort());
await eng.close();
});
it('SQL 层索引范围查询(GROUP BY 前过滤)一致性', async () => {
const db = new MetonaSqlark({ name: uniqueDB(), mode: 'aria', diskEngine: 'memory' });
await db.init();
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
score: { type: 'number', index: true },
});
await db.query('INSERT INTO t VALUES (\'1\', 1.5), (\'2\', 2.5), (\'3\', 3.5)');
const rows = await db.query('SELECT * FROM t WHERE score > 2') as Record<string, unknown>[];
expect(rows.map((r) => r.id).sort()).toEqual(['2', '3']);
await db.close();
});
});
describe('v0.6.2 — Aria 索引列 IS NULL', () => {
it('引擎层 $isNull 命中 null 行', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', index: true },
}));
await eng.insert('t', [{ id: '1', email: 'a@b.c' }, { id: '2', email: null }, { id: '3', email: 'd@e.f' }]);
// v0.8.0A15):此断言此前写作 `{ $eq: null }` 并期望命中 NULL 行 —— 那是
// 错误的 SQL 语义(`= NULL` 恒为 UNKNOWN)。索引列 IS NULL 的**目的**
//"NULL 行不能被索引漏掉")不变,改为用正确的谓词表达。
const rows = await eng.find('t', { table: 't', where: { email: { $isNull: true } } });
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe('2');
await eng.close();
});
it('引擎层 $eq: null 不再命中 null 行(SQL 标准)', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', index: true },
}));
await eng.insert('t', [{ id: '1', email: 'a@b.c' }, { id: '2', email: null }]);
const rows = await eng.find('t', { table: 't', where: { email: { $eq: null } } });
expect(rows).toHaveLength(0);
await eng.close();
});
it('SQL 层 IS NULL / IS NOT NULL 正确', async () => {
const db = new MetonaSqlark({ name: uniqueDB(), mode: 'aria', diskEngine: 'memory' });
await db.init();
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', index: true },
});
await db.query('INSERT INTO t VALUES (\'1\', \'a@b.c\'), (\'2\', NULL)');
const nullRows = await db.query('SELECT * FROM t WHERE email IS NULL') as Record<string, unknown>[];
expect(nullRows).toHaveLength(1);
expect(nullRows[0].id).toBe('2');
const notNull = await db.query('SELECT * FROM t WHERE email IS NOT NULL') as Record<string, unknown>[];
expect(notNull).toHaveLength(1);
expect(notNull[0].id).toBe('1');
await db.close();
});
it('IN 列表含 null 不走索引(不漏可匹配行)', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', index: true },
}));
await eng.insert('t', [{ id: '1', email: 'a@b.c' }, { id: '2', email: null }]);
// v0.8.0A15):`x IN (NULL, 'a@b.c')` 等价于 `x = NULL OR x = 'a@b.c'`
// `null = NULL` 是 UNKNOWN(不成立),所以只有 id=1 命中。
// 此前的期望值 2 正是"NULL 与 NULL 相等"这一错误语义的产物。
const rows = await eng.find('t', { table: 't', where: { email: { $in: [null, 'a@b.c'] } } });
expect(rows.map((r) => r.id)).toEqual(['1']);
await eng.close();
});
it('IN 列表含 null 时不可匹配的行仍不返回(UNKNOWN 不保留)', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', index: true },
}));
await eng.insert('t', [{ id: '1', email: 'a@b.c' }, { id: '2', email: null }, { id: '3', email: 'z@z.z' }]);
// id=3 既不在列表里,列表又含 NULL → 结果 UNKNOWN → 不保留
const rows = await eng.find('t', { table: 't', where: { email: { $in: [null, 'a@b.c'] } } });
expect(rows.map((r) => r.id)).toEqual(['1']);
await eng.close();
});
});
describe('v0.6.2 — Aria unique 约束强制', () => {
it('同批重复唯一值被拦截', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
}));
await expectCode(eng.insert('t', [
{ id: '1', email: 'x@y' }, { id: '2', email: 'x@y' },
]), 'UNIQUE_VIOLATION');
// 验证失败整批不落库
const rows = await eng.find('t', { table: 't' });
expect(rows).toHaveLength(0);
await eng.close();
});
it('跨批重复唯一值被拦截', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
}));
await eng.insert('t', [{ id: '1', email: 'x@y' }]);
await expectCode(eng.insert('t', [{ id: '2', email: 'x@y' }]), 'UNIQUE_VIOLATION');
await eng.close();
});
it('flush 后(索引进 SSTable)重复唯一值仍被拦截', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
}));
await eng.insert('t', [{ id: '1', email: 'x@y' }]);
await (eng as any).secondaryIndexes.get('t:idx:email').flush();
await (eng as any).lsm.flush();
await expectCode(eng.insert('t', [{ id: '2', email: 'x@y' }]), 'UNIQUE_VIOLATION');
await eng.close();
});
it('update 改为已存在唯一值被拦截;改为自身旧值放行', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
}));
await eng.insert('t', [{ id: '1', email: 'a@y' }, { id: '2', email: 'b@y' }]);
await expectCode(eng.update('t', { table: 't', where: { id: '1' } }, { email: 'b@y' }), 'UNIQUE_VIOLATION');
// 更新为自身旧值(未变化)放行
await eng.update('t', { table: 't', where: { id: '1' } }, { email: 'a@y' });
const rows = await eng.find('t', { table: 't' });
expect(rows).toHaveLength(2);
await eng.close();
});
it('非主键 update 清理旧索引条目:旧值可被新行复用', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
}));
await eng.insert('t', [{ id: '1', email: 'a@y' }]);
await eng.update('t', { table: 't', where: { id: '1' } }, { email: 'b@y' });
// 旧值 a@y 应可被复用(残留索引条目否则会误报 UNIQUE_VIOLATION
await eng.insert('t', [{ id: '2', email: 'a@y' }]);
const rows = await eng.find('t', { table: 't' });
expect(rows).toHaveLength(2);
await eng.close();
});
it('null 值不受唯一约束', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
}));
await eng.insert('t', [{ id: '1' }, { id: '2' }]);
const rows = await eng.find('t', { table: 't' });
expect(rows).toHaveLength(2);
await eng.close();
});
it('数值唯一列 + 事务内重复拦截', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
id: { type: 'string', primaryKey: true },
code: { type: 'number', unique: true },
}));
await eng.insert('t', [{ id: '1', code: 100 }]);
await eng.beginTransaction();
await expectCode(eng.insert('t', [{ id: '2', code: 100 }]), 'UNIQUE_VIOLATION');
await eng.rollbackTransaction();
const rows = await eng.find('t', { table: 't' });
expect(rows).toHaveLength(1);
await eng.close();
});
});
describe('v0.6.2 — EXPLAIN 无副作用', () => {
it('EXPLAIN DELETE/UPDATE 不修改数据', async () => {
const db = new MetonaSqlark({ name: uniqueDB(), mode: 'memory' });
await db.init();
await db.defineTable('users', {
id: { type: 'string', primaryKey: true },
name: { type: 'string' },
});
await db.query('INSERT INTO users VALUES (\'1\', \'Alice\'), (\'2\', \'Bob\')');
await db.query('EXPLAIN DELETE FROM users WHERE id = \'1\'');
let rows = await db.query('SELECT * FROM users') as Record<string, unknown>[];
expect(rows).toHaveLength(2);
await db.query('EXPLAIN UPDATE users SET name = \'X\' WHERE id = \'1\'');
rows = await db.query('SELECT * FROM users') as Record<string, unknown>[];
expect(rows).toHaveLength(2);
expect(rows.find((r) => r.id === '1')!.name).toBe('Alice');
// EXPLAIN DELETE 给出估算行数(无副作用)
const plan = await db.query('EXPLAIN DELETE FROM users WHERE id = \'1\'') as Record<string, unknown>;
expect(plan.estimatedRows).toBe(1);
await db.close();
});
it('EXPLAIN INSERT 不写入数据', async () => {
const db = new MetonaSqlark({ name: uniqueDB(), mode: 'memory' });
await db.init();
await db.defineTable('users', { id: { type: 'string', primaryKey: true } });
await db.query('EXPLAIN INSERT INTO users VALUES (\'1\')');
const rows = await db.query('SELECT * FROM users') as Record<string, unknown>[];
expect(rows).toHaveLength(0);
await db.close();
});
});