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
+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']);
});
});
}
});