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 零错误。
This commit is contained in:
thzxx
2026-09-14 23:22:26 +08:00
parent 674da6b7b7
commit 4ab04df882
9 changed files with 929 additions and 162 deletions
+15 -2
View File
@@ -74,12 +74,17 @@ describe('Parser 边缘场景', () => {
it('WHERE IS NULL', () => {
const ast = asSelect(parse('SELECT * FROM users WHERE bio IS NULL'));
expect(ast.where).toEqual({ bio: { $eq: null } });
// v0.8.0A15):IS NULL 是**谓词**,不是 `$eq: null`。
// `WHERE bio = NULL` 在 SQL 里恒为 UNKNOWN(空集),只有 IS NULL 能命中 NULL 行;
// 此前两者编译成同一个 `{ $eq: null }`,于是任何调用方都无法表达"等于 NULL"。
expect(ast.where).toEqual({ bio: { $isNull: true } });
});
it('WHERE IS NOT NULL', () => {
const ast = asSelect(parse('SELECT * FROM users WHERE bio IS NOT NULL'));
expect(ast.where).toEqual({ bio: { $ne: null } });
// 同理:`$ne: null` 在老语义下会返回所有非 NULL 行,看起来"对"
// 但它与 `!= NULL`(应为空集)共用同一个 AST,语义无法区分。
expect(ast.where).toEqual({ bio: { $isNotNull: true } });
});
it('WHERE NOT', () => {
@@ -235,6 +240,14 @@ describe('Parser 边缘场景', () => {
it('NULL 值', () => {
const ast = asSelect(parse('SELECT * FROM users WHERE bio IS NULL'));
// v0.8.0A15):见上方 "WHERE IS NULL" —— 解析为谓词而非与 NULL 的比较
expect(ast.where).toEqual({ bio: { $isNull: true } });
});
it('与 NULL 的比较保留为比较运算(结果为 UNKNOWN)', () => {
// 回归护栏:`= NULL` 必须编译成 `$eq: null`(而不是被"优化"成 $isNull)。
// 两者结果集完全不同:前者空集,后者命中 NULL 行。
const ast = asSelect(parse('SELECT * FROM users WHERE bio = NULL'));
expect(ast.where).toEqual({ bio: { $eq: null } });
});
});
+37 -4
View File
@@ -208,7 +208,7 @@ describe('v0.6.2 — Aria 二级索引范围查询边界', () => {
});
describe('v0.6.2 — Aria 索引列 IS NULL', () => {
it('引擎层 $eq: null 返回 null 行', async () => {
it('引擎层 $isNull 命中 null 行', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
@@ -216,12 +216,28 @@ describe('v0.6.2 — Aria 索引列 IS NULL', () => {
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' }]);
const rows = await eng.find('t', { table: 't', where: { email: { $eq: null } } });
// 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();
@@ -239,7 +255,7 @@ describe('v0.6.2 — Aria 索引列 IS NULL', () => {
await db.close();
});
it('IN 列表含 null 不走索引(不漏 null 行)', async () => {
it('IN 列表含 null 不走索引(不漏可匹配行)', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', {
@@ -247,8 +263,25 @@ describe('v0.6.2 — Aria 索引列 IS NULL', () => {
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).toHaveLength(2);
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();
});
});
+20 -2
View File
@@ -38,18 +38,36 @@ describe('v0.7.3: 索引列 IS NULL(三引擎对齐)', () => {
test.each([
['memory', { mode: 'memory' } as const],
['disk', { mode: 'disk', diskEngine: 'memory' } as const],
])('%s: 索引列 $eq: nullQuery Builder)不走索引短路', async (_label, cfg) => {
])('%s: 唯一索引列 $isNullQuery Builder)不走索引短路', async (_label, cfg) => {
const db = await MetonaSqlark.create({ name: `v073-isnull-qb-${_label}`, ...cfg });
await db.defineTable('users', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
});
await db.query("INSERT INTO users VALUES ('1', NULL), ('2', 'a@b.c')");
const rows = await db.table('users').select(['id']).where({ email: { $eq: null } }).execute();
// v0.8.0A15):此处原为 `{ $eq: null }` 且期望命中 NULL 行 —— 那是错误的
// SQL 语义。测试的真实目标是"唯一索引上的 NULL 行不被索引短路漏掉",
// 用正确谓词表达后目标不变(见下一条对 `$eq: null` 的语义护栏)。
const rows = await db.table('users').select(['id']).where({ email: { $isNull: true } }).execute();
expect(rows).toHaveLength(1);
await db.close();
});
test.each([
['memory', { mode: 'memory' } as const],
['disk', { mode: 'disk', diskEngine: 'memory' } as const],
])('%s: `$eq: null` 恒 UNKNOWN —— 不再命中 NULL 行', async (_label, cfg) => {
const db = await MetonaSqlark.create({ name: `v073-eqnull-qb-${_label}`, ...cfg });
await db.defineTable('users', {
id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true },
});
await db.query("INSERT INTO users VALUES ('1', NULL), ('2', 'a@b.c')");
const rows = await db.table('users').select(['id']).where({ email: { $eq: null } }).execute();
expect(rows).toHaveLength(0);
await db.close();
});
test('aria: IS NULL 回归护栏(v0.6.2 已修)', async () => {
const db = await MetonaSqlark.create({ name: 'v073-isnull-aria', mode: 'aria', diskEngine: 'memory' });
await db.defineTable('users', {
+140
View File
@@ -0,0 +1,140 @@
/**
* v0.8.0 回归套件:SQL 三值逻辑(NULL 语义)—— PB-2
* ============================================================================
* 本套件锁定 PLAN-v0.7.5.md 根因 7"未解析/UNKNOWN 静默变 false")与
* 附录 §5 缺陷 A15 的修复契约,并对**四种存储引擎**逐一验证同一语义。
*
* 历史缺陷(本套件即为其反例):
* 1. `WHERE n = NULL` 命中 NULL 行(`===` 比较 null === null);
* 2. `WHERE n != NULL` 返回所有非 NULL 行;
* 3. `WHERE n NOT BETWEEN 1 AND 2` 恒空集 —— parser 生成的字段级
* `{ n: { $or: [ { $lt: 1 }, { $gt: 2 } ] } }` 被求值成"查询字段 `$lt`"
* 每行都是 UNKNOWN;根因是字段级 `$or` 递归进了 where 子句级求值器。
*
* 参考数据:
* id=1 n=1 s='x'
* id=2 n=2 s=NULL
* id=3 n=NULL s='x'
* id=4 n=3 s='y'
*/
import { MetonaSqlark } from '../src/core';
import type { DatabaseConfig } from '../src/constants';
import { rows as rowsOf } from './helpers/assertions';
interface EngineCase {
label: string;
mode: DatabaseConfig['mode'];
extra?: Partial<DatabaseConfig>;
}
const ENGINES: EngineCase[] = [
{ label: 'memory', mode: 'memory' },
{ label: 'disk', mode: 'disk' },
{ label: 'hybrid', mode: 'hybrid' },
{ label: 'aria', mode: 'aria', extra: { diskEngine: 'memory' } },
];
/** [SQL, 期望 id 集合] */
const MATRIX: Array<[sql: string, expected: string[]]> = [
// --- 与 NULL 的比较:结果 UNKNOWNWHERE 不保留 ---
['SELECT id FROM t WHERE n = NULL', []],
['SELECT id FROM t WHERE n != NULL', []],
['SELECT id FROM t WHERE n <> NULL', []],
// --- IS NULL / IS NOT NULL 是谓词,不走比较 ---
['SELECT id FROM t WHERE n IS NULL', ['3']],
['SELECT id FROM t WHERE n IS NOT NULL', ['1', '2', '4']],
["SELECT id FROM t WHERE s IS NULL", ['2']],
// --- IN / NOT IN:列表含 NULL 且未命中 → UNKNOWN ---
['SELECT id FROM t WHERE n IN (1, NULL)', ['1']],
['SELECT id FROM t WHERE n NOT IN (1, 2)', ['4']],
["SELECT id FROM t WHERE s NOT IN ('x')", ['4']],
['SELECT id FROM t WHERE n IN (1, 2)', ['1', '2']],
// --- LIKE / NOT LIKENULL 操作数 → UNKNOWN ---
["SELECT id FROM t WHERE s LIKE 'x'", ['1', '3']],
["SELECT id FROM t WHERE s NOT LIKE 'x'", ['4']],
// --- NOTUNKNOWN 取反仍是 UNKNOWN ---
['SELECT id FROM t WHERE NOT (n = 1)', ['2', '4']],
['SELECT id FROM t WHERE NOT (n = 4)', ['1', '2', '4']],
// --- BETWEEN / NOT BETWEEN(本套件的核心回归点)---
['SELECT id FROM t WHERE n BETWEEN 1 AND 2', ['1', '2']],
['SELECT id FROM t WHERE n NOT BETWEEN 1 AND 2', ['4']],
['SELECT id FROM t WHERE s BETWEEN \'a\' AND \'z\'', ['1', '3', '4']],
["SELECT id FROM t WHERE s NOT BETWEEN 'a' AND 'z'", []],
// --- 字段级 $or / $not 与 where 级逻辑组合的一致性 ---
['SELECT id FROM t WHERE n > 1 OR n < 0', ['2', '4']],
['SELECT id FROM t WHERE n > 0 AND n < 3', ['1', '2']],
["SELECT id FROM t WHERE s = 'x' OR n IS NULL", ['1', '3']],
["SELECT id FROM t WHERE s = 'x' AND n IS NULL", ['3']],
["SELECT id FROM t WHERE s != 'x'", ['4']],
// --- 裸列作为布尔条件(字段存在性)不应因 NULL 崩溃 ---
['SELECT id FROM t WHERE n >= 3', ['4']],
['SELECT id FROM t WHERE n <= 1', ['1']],
];
describe('v0.8.0 SQL 三值逻辑(PB-2 / A15', () => {
for (const engine of ENGINES) {
describe(`${engine.label} 引擎`, () => {
let db: MetonaSqlark;
beforeAll(async () => {
db = await MetonaSqlark.create({
name: `sql-three-valued-${engine.label}`,
mode: engine.mode,
...(engine.extra ?? {}),
});
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
n: { type: 'number' },
s: { type: 'string' },
});
await db.query(
"INSERT INTO t (id, n, s) VALUES ('1', 1, 'x'), ('2', 2, NULL), ('3', NULL, 'x'), ('4', 3, 'y')",
);
});
afterAll(async () => {
await db.close();
});
it.each(MATRIX)('%s → %j', async (sql, expected) => {
const rows = rowsOf<Record<string, unknown>>(await db.query(sql));
const got = rows.map((row) => row.id as string).sort();
expect(got).toEqual([...expected].sort());
});
it('UPDATE / DELETE 使用同一套三值语义', async () => {
// 独立表,避免污染上面的只读矩阵
await db.defineTable('u', {
id: { type: 'string', primaryKey: true },
n: { type: 'number' },
});
await db.query("INSERT INTO u (id, n) VALUES ('a', 1), ('b', NULL), ('c', 3)");
// `n > 1` 只命中 cNULL → UNKNOWN
await db.query("UPDATE u SET n = 9 WHERE n > 1");
const afterUpdate = rowsOf<Record<string, unknown>>(await db.query('SELECT id, n FROM u ORDER BY id'));
expect(afterUpdate.map((r) => [r.id, r.n])).toEqual([
['a', 1],
['b', null],
['c', 9],
]);
// `n = NULL` 匹配 0 行(不能把 NULL 行删掉)
await db.query('DELETE FROM u WHERE n = NULL');
expect(rowsOf(await db.query('SELECT id FROM u ORDER BY id'))).toHaveLength(3);
// `n IS NULL` 命中 b
await db.query('DELETE FROM u WHERE n IS NULL');
const survivors = rowsOf<Record<string, unknown>>(await db.query('SELECT id FROM u ORDER BY id'));
expect(survivors.map((r) => r.id)).toEqual(['a', 'c']);
});
});
}
});