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:
@@ -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.0(A15):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.0(A15):见上方 "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 } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.0(A15):此断言此前写作 `{ $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.0(A15):`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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: null(Query Builder)不走索引短路', async (_label, cfg) => {
|
||||
])('%s: 唯一索引列 $isNull(Query 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.0(A15):此处原为 `{ $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', {
|
||||
|
||||
@@ -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 的比较:结果 UNKNOWN,WHERE 不保留 ---
|
||||
['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 LIKE:NULL 操作数 → UNKNOWN ---
|
||||
["SELECT id FROM t WHERE s LIKE 'x'", ['1', '3']],
|
||||
["SELECT id FROM t WHERE s NOT LIKE 'x'", ['4']],
|
||||
|
||||
// --- NOT:UNKNOWN 取反仍是 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` 只命中 c(NULL → 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']);
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user