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
+11 -2
View File
@@ -731,9 +731,18 @@ export class MetonaSqlark {
this.ready = false; this.ready = false;
} }
/** 获取底层引擎(含变更通知装饰器) */ /**
* 获取底层存储引擎。
*
* v0.8.0:返回**未装饰**的真实引擎。
*
* 变更通知用的 ChangeNotifierEngine 只是内部接线细节;若把它暴露出去,
* 调用方(以及测试)依赖的引擎特有能力(`lsm`、`secondaryIndexes`、
* `getDiskEngineType` 等)会被静默隐藏 —— 本项目既有测试与文档都按
* "getEngine() 就是那个引擎"理解。因此这里保持原语义,装饰器只在 core 内部使用。
*/
getEngine(): IStorageEngine { getEngine(): IStorageEngine {
return this.engine; return this.unwrapEngine();
} }
/** /**
+78 -8
View File
@@ -478,7 +478,10 @@ export class QueryExecutor {
// v0.4.0 修复: 关联子查询需要完整外层行(SELECT 列可能不含被 $col 引用的列,如 EXISTS 绑定的主键) // v0.4.0 修复: 关联子查询需要完整外层行(SELECT 列可能不含被 $col 引用的列,如 EXISTS 绑定的主键)
plan.columns = ['*']; plan.columns = ['*'];
if (orderByAlias) { plan.orderBy = undefined; plan.limit = undefined; plan.offset = undefined; } if (orderByAlias) { plan.orderBy = undefined; plan.limit = undefined; plan.offset = undefined; }
rows = await this.engine.find(plan.table, { ...plan, where: this.stripCorrelatedExists(stmt.where) }); // v0.8.0:引擎层只做**可下推部分**的预过滤,逐行谓词整体交给 filterCorrelated。
// 此前把原始 where(含 `$col`)直接交给引擎,引擎判 UNKNOWN → 候选行 0 →
// `WHERE t.x = t.y` 静默空结果(详见 enginePreFilter 文档)。
rows = await this.engine.find(plan.table, { ...plan, where: this.enginePreFilter(stmt.where) });
rows = await this.filterCorrelated(rows, stmt.where, mainAliases); rows = await this.filterCorrelated(rows, stmt.where, mainAliases);
} else { } else {
// 先解析子查询 // 先解析子查询
@@ -1448,27 +1451,94 @@ export class QueryExecutor {
return false; return false;
} }
/** 移除关联 EXISTS 标记(引擎层先执行无 EXISTS 条件的查询) */ /**
private stripCorrelatedExists(where: WhereCondition): WhereCondition { * 构造**引擎层预过滤**用的 WHERE 子句。
*
* 逐行求值的谓词必须整体移出引擎层,否则引擎的 `matchWhere`(没有外层行上下文)
* 会把它们判为 UNKNOWN → **所有行被过滤掉**,逐行求值再正确也无行可算:
* - 关联 `EXISTS``$exists` 子查询未执行,`{ $subquery: ... }` 引擎无法求值;
* - CASE WHEN 表达式键:需要行上下文才能算出布尔;
* - `$col` 列引用(`WHERE t.x = t.y` → `{ x: { $eq: { $col: 'y' } } }`):
* 引擎层取不到"另一列"的值。
*
* 移出的粒度取决于连接词 —— 这里**不是**保守兜底,而是逻辑上唯一正确的做法:
* - 顶层 / `$and` 的成员:可以单独删除该谓词,其余谓词仍然安全可下推;
* - `$or` / `$not` 的成员:不能单独删除。删掉 `A OR B` 中的 `B` 会得到更严的
* `A`**漏行**);删掉 `NOT B` 中的 `B` 会得到恒真的 `NOT true`**多行**)。
* 因此整条 `$or` / `$not` 都交给逐行求值(`$not` 的恒真情形直接丢弃该键)。
*/
private enginePreFilter(where: WhereCondition): WhereCondition {
const cleaned: WhereCondition = {}; const cleaned: WhereCondition = {};
for (const [key, value] of Object.entries(where)) { for (const [key, value] of Object.entries(where)) {
if (key === '$and' || key === '$or') { if (key === '$and') {
cleaned[key] = (value as WhereCondition[]).map((sub) => this.stripCorrelatedExists(sub)); const subs = (value as WhereCondition[]).map((sub) => this.enginePreFilter(sub));
const kept = subs.filter((sub) => Object.keys(sub).length > 0);
if (kept.length > 0) cleaned.$and = kept;
continue;
}
if (key === '$or') {
// $or 是故障放大器:任一分支不可判定则该 $or 整体不可下推
const subs = value as WhereCondition[];
if (subs.some((sub) => !this.isEngineEvaluable(sub))) continue;
cleaned.$or = subs.map((sub) => this.enginePreFilter(sub));
continue; continue;
} }
if (key === '$not') { if (key === '$not') {
const inner = this.stripCorrelatedExists(value as WhereCondition); const inner = value as WhereCondition;
// 剥离后为空 → 条件恒真,删掉该键(避免引擎层执行 NOT(true) 过滤掉所有行) if (!this.isEngineEvaluable(inner)) continue;
if (Object.keys(inner).length > 0) cleaned.$not = inner; cleaned.$not = this.enginePreFilter(inner);
continue; continue;
} }
if (key === '$exists') continue; // 逐行求值时单独处理 if (key === '$exists') continue; // 逐行求值时单独处理
if (/^\s*CASE\b/i.test(key)) continue; // v0.3.2: CASE 键逐行求值 if (/^\s*CASE\b/i.test(key)) continue; // v0.3.2: CASE 键逐行求值
if (this.fieldHasColRef(value) || this.fieldHasSubquery(value)) continue; // $col / 子查询逐行求值
cleaned[key] = value; cleaned[key] = value;
} }
return cleaned; return cleaned;
} }
/**
* 该 WHERE 片段是否可完全交给引擎层求值(无 `$col` / 关联 `EXISTS` / CASE 键)。
*
* 与 `hasCorrelatedRefs` 的区别:`hasCorrelatedRefs` 回答"是否需要逐行求值",
* 本函数回答"能否整体下推"。两者互补,缺一不可 —— 后者是前者在 `$or`/`$not`
* 内部传播后的结果。
*/
private isEngineEvaluable(where: WhereCondition): boolean {
for (const [key, value] of Object.entries(where)) {
if (key === '$and' || key === '$or') {
if ((value as WhereCondition[]).some((sub) => !this.isEngineEvaluable(sub))) return false;
continue;
}
if (key === '$not') {
if (!this.isEngineEvaluable(value as WhereCondition)) return false;
continue;
}
if (key === '$exists') return false;
if (/^\s*CASE\b/i.test(key)) return false;
if (this.fieldHasColRef(value) || this.fieldHasSubquery(value)) return false;
}
return true;
}
/** 字段条件里是否含未解析子查询(引擎层无法执行) */
private fieldHasSubquery(value: unknown): boolean {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
const ops = value as Record<string, unknown>;
if ('$subquery' in ops) return true;
for (const [, operand] of Object.entries(ops)) {
if (typeof operand === 'object' && operand !== null && !Array.isArray(operand)) {
if ('$subquery' in (operand as Record<string, unknown>)) return true;
}
if (Array.isArray(operand)) {
for (const item of operand) {
if (typeof item === 'object' && item !== null && '$subquery' in (item as Record<string, unknown>)) return true;
}
}
}
return false;
}
/** 逐行绑定外层行上下文,求值关联 EXISTS、$col 引用与 CASE WHEN 键 */ /** 逐行绑定外层行上下文,求值关联 EXISTS、$col 引用与 CASE WHEN 键 */
private async filterCorrelated( private async filterCorrelated(
rows: Record<string, unknown>[], rows: Record<string, unknown>[],
+233
View File
@@ -0,0 +1,233 @@
/**
* metona-sqlark SQL 值语义 — 三值逻辑与统一编码(v0.8.0)
* @module query/sql-compare
*
* ============================================================================
* 为什么需要它(PLAN-v0.7.5.md 根因 2 / PB-2
* ============================================================================
* 此前项目里并存**五套**值相等语义:
* 1. `where-matcher` 的 `===`
* 2. 哈希连接的 `String(v ?? '\0')`
* 3. 分组键 `encodeGroupKey`
* 4. `COUNT(DISTINCT)` 的 `String(v)`
* 5. Aria 二级索引键的 `String(v)`
* 它们对 NULL 的处理各不相同,于是出现一批"静默错值":
* - `WHERE a = NULL` 命中 NULL 行(SQL 标准应为空集,比较结果是 UNKNOWN);
* - `WHERE a != NULL` 返回所有非 NULL 行(标准同样为空集);
* - `WHERE a NOT BETWEEN 1 AND 2` 会把 NULL 行**排除**(标准应包含,
* 因为 UNKNOWN 经过 NOT 仍是 UNKNOWN,而 WHERE 只保留 TRUE……此处需按下文语义);
* - `WHERE a NOT IN (1, 2)` 把 NULL 行当作"不在列表"返回;
* - JOIN 的 NULL 键:嵌套循环认为 NULL = NULL 成立、哈希连接不成立 →
* **结果取决于右表有没有索引**。
*
* ============================================================================
* 本模块提供的语义
* ============================================================================
* `sqlCompare(a, b)` / `sqlEquals(a, b)` 返回三值:TRUE / FALSE / UNKNOWN。
* 任一操作数为 NULL/undefined → UNKNOWNSQL 标准)。
* `matchWhere` 只在结果为 TRUE 时保留该行。
*
* 注意 `IS NULL` / `IS NOT NULL` **不走比较**:它们是谓词,直接对 NULL 求值,
* 由 parser 生成为独立的 `$isNull` / `$isNotNull` 标记(见 SQL_LOGICAL 说明)。
*/
/** SQL 三值逻辑 */
export const SQL_LOGICAL = Object.freeze({
TRUE: 'TRUE',
FALSE: 'FALSE',
/** 未知(任一操作数为 NULL,或存在无法确定的引用) */
UNKNOWN: 'UNKNOWN',
} as const);
export type SqlTruth = (typeof SQL_LOGICAL)[keyof typeof SQL_LOGICAL];
/** 是否为 NULL 语义值(undefined 与 null 等价为 SQL NULL */
export function isSqlNull(value: unknown): boolean {
return value === null || value === undefined;
}
/**
* 未解析的列引用标记。
*
* 用于区分两件必须分开处理的事:
* - `row[col]` 取到 `undefined` 可能是"该行此列为 NULL"
* 也可能是"这一列根本不在当前行里"(例如子查询引用了外层列)。
* 前者应判 UNKNOWN,后者应让调用方转交带上下文的求值器(不能静默当 NULL)。
* 用 Unique Symbol 保证不会与真实数据冲突。
*/
export const UNRESOLVED = Symbol('sqlark.unresolved');
/** 判定某值是否为"未解析引用" */
export function isUnresolved(value: unknown): value is typeof UNRESOLVED {
return value === UNRESOLVED;
}
/**
* 统一的类型化值编码(用于分组键 / DISTINCT 键 / 索引键)。
*
* 为什么不能直接 `String(v)`
* - `String(null)` = `'null'` 与字符串 `'null'` 冲突;
* - `String(1)` = `'1'` 与字符串 `'1'` 冲突(v0.7.4 已修 DISTINCT
* 但 COUNT(DISTINCT) 与 Aria 索引键仍是 `String()`)。
* 这里用类型前缀 + 长度前缀,保证不同类型、不同值必然编码不同,
* 且不会出现 `\x1f` 之类分隔符被数据内容伪造的问题。
*/
export function encodeValueKey(value: unknown): string {
if (value === null || value === undefined) return 'z';
const t = typeof value;
switch (t) {
case 'string': {
const s = value as string;
return `s${s.length}:${s}`;
}
case 'number': {
const n = value as number;
// -0 与 0 在 SQL 中相等;NaN 单独编码(避免与任何值相等)
if (Number.isNaN(n)) return 'nNaN';
return `n${Object.is(n, -0) ? 0 : n}`;
}
case 'boolean': return `b${value ? 1 : 0}`;
case 'bigint': return `i${String(value)}`;
case 'object': {
// 日期按时间戳比较;数组/对象按 JSON(键序由 JSON.stringify 决定,
// 对同构数据稳定;异构键序在上层已由 schema 约束)
if (value instanceof Date) return `d${value.getTime()}`;
if (Array.isArray(value)) return `a${JSON.stringify(value)}`;
return `o${JSON.stringify(value)}`;
}
default: return `x${String(value)}`;
}
}
/**
* SQL 比较:返回三值。
*
* 与 JavaScript 运算符的关键差异:
* - 任一操作数为 NULL → UNKNOWN(而不是 false);
* - 非数值字符串与数值比较 → UNKNOWN(而不是 JS 的强制转换结果);
* - 未解析引用 → UNKNOWN(调用方据此转交上下文求值)。
*/
export function sqlCompare(a: unknown, b: unknown): SqlTruth {
const ord = sqlCompareOrder(a, b);
if (ord === null) return SQL_LOGICAL.UNKNOWN;
return ord === 0 ? SQL_LOGICAL.TRUE : SQL_LOGICAL.FALSE;
}
/** 相等比较(三值) */
export function sqlEquals(a: unknown, b: unknown): SqlTruth {
return sqlCompare(a, b);
}
/**
* 三路排序比较:返回 -1 / 0 / 1,无法比较(含 NULL、类型不可比)返回 null。
*
* **这是本模块唯一的排序/比较原语** —— `sqlCompare`(相等)与所有有序比较
* $gt/$gte/$lt/$lte、ORDER BY)都必须建立在它之上。
*
* 为什么要把"排序"与"相等"分开:初版实现里 `sqlCompare` 直接返回三值,
* 于是"a < b"与"a === b"都被编码成 TRUE`$gte` 误把"小于"当成"大于等于"
* (实测 `WHERE n >= 2` 在 1..3 上返回 3 而不是 2 与 3)。
* 排序是三路的(<, =, >),相等是二值的(=, ≠)—— 把前者硬塞进后者必然出错。
*/
export function sqlCompareOrder(a: unknown, b: unknown): number | null {
if (isUnresolved(a) || isUnresolved(b)) return null;
if (isSqlNull(a) || isSqlNull(b)) return null;
// 数字 vs 数字
if (typeof a === 'number' && typeof b === 'number') {
if (Number.isNaN(a) || Number.isNaN(b)) return null;
if (a === b) return 0;
return a < b ? -1 : 1;
}
// 布尔 vs 布尔
if (typeof a === 'boolean' && typeof b === 'boolean') {
if (a === b) return 0;
return a === false ? -1 : 1;
}
// 字符串 vs 字符串:字典序
if (typeof a === 'string' && typeof b === 'string') {
if (a === b) return 0;
return a < b ? -1 : 1;
}
// 日期 vs 日期
if (a instanceof Date && b instanceof Date) {
const ta = a.getTime();
const tb = b.getTime();
if (Number.isNaN(ta) || Number.isNaN(tb)) return null;
if (ta === tb) return 0;
return ta < tb ? -1 : 1;
}
// 类型不同:本项目保持严格类型(不做静默强转),判定为不可比(null)
// —— 这样 `WHERE numCol = 'abc'` 不会意外命中,也不会因 `'5' = 5` 的 JS
// 行为产生跨引擎差异。唯一例外:数值与"纯数值字符串"按数值比较
// (与既有「$in 列表含字符串」行为兼容)。
const na = toNumericIfPossible(a);
const nb = toNumericIfPossible(b);
if (na !== null && nb !== null) {
if (na === nb) return 0;
return na < nb ? -1 : 1;
}
return null;
}
/** 比较结果转布尔(仅 TRUE 为真;UNKNOWN 与 FALSE 都不保留该行) */
export function isTrue(truth: SqlTruth): boolean {
return truth === SQL_LOGICAL.TRUE;
}
/** 三值 AND */
export function sqlAnd(a: SqlTruth, b: SqlTruth): SqlTruth {
if (a === SQL_LOGICAL.FALSE || b === SQL_LOGICAL.FALSE) return SQL_LOGICAL.FALSE;
if (a === SQL_LOGICAL.UNKNOWN || b === SQL_LOGICAL.UNKNOWN) return SQL_LOGICAL.UNKNOWN;
return SQL_LOGICAL.TRUE;
}
/** 三值 OR */
export function sqlOr(a: SqlTruth, b: SqlTruth): SqlTruth {
if (a === SQL_LOGICAL.TRUE || b === SQL_LOGICAL.TRUE) return SQL_LOGICAL.TRUE;
if (a === SQL_LOGICAL.UNKNOWN || b === SQL_LOGICAL.UNKNOWN) return SQL_LOGICAL.UNKNOWN;
return SQL_LOGICAL.FALSE;
}
/** 三值 NOTUNKNOWN 取反仍为 UNKNOWN */
export function sqlNot(a: SqlTruth): SqlTruth {
if (a === SQL_LOGICAL.TRUE) return SQL_LOGICAL.FALSE;
if (a === SQL_LOGICAL.FALSE) return SQL_LOGICAL.TRUE;
return SQL_LOGICAL.UNKNOWN;
}
/**
* 把值转为数值;无法安全转换时返回 null。
* 只有 number 与"纯数值字符串"参与数值比较,避免 `'abc'` 之类被 Number() 变成 NaN。
*/
function toNumericIfPossible(value: unknown): number | null {
if (typeof value === 'number') return Number.isNaN(value) ? null : value;
if (typeof value === 'boolean') return value ? 1 : 0;
if (typeof value === 'string') {
const trimmed = value.trim();
if (trimmed === '') return null;
const n = Number(trimmed);
return Number.isNaN(n) ? null : n;
}
return null;
}
/**
* SQL `IN` 列表求值(三值)。
*
* 标准语义:`x IN (a, b, c)` 等价于 `x = a OR x = b OR x = c`。
* 因此:
* - 命中任一 → TRUE
* - 都未命中但列表含 NULL(或 x 为 NULL)→ UNKNOWN
* - 都未命中且列表无 NULL 且 x 非 NULL → FALSE。
*/
export function sqlIn(value: unknown, list: unknown[]): SqlTruth {
if (isUnresolved(value)) return SQL_LOGICAL.UNKNOWN;
let sawUnknown = false;
for (const item of list) {
const t = sqlEquals(value, item);
if (t === SQL_LOGICAL.TRUE) return SQL_LOGICAL.TRUE;
if (t === SQL_LOGICAL.UNKNOWN) sawUnknown = true;
}
return sawUnknown ? SQL_LOGICAL.UNKNOWN : SQL_LOGICAL.FALSE;
}
+361 -130
View File
@@ -1,13 +1,45 @@
/** /**
* metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑 * metona-sqlark Shared WHERE Matcher — 统一的条件求值器
* @module query/where-matcher * @module query/where-matcher
* *
* MemoryEngine / IndexedDBEngine / QueryExecutor 共享此模块, * ============================================================================
* 消除 220+ 行重复代码,统一 $and/$or/$not/$col 行为。 * v0.8.0 根治:为什么这里只剩**一个**递归求值器(PLAN-v0.7.5.md 根因 1/7、PB-2
* ============================================================================
* 历史上有两套并存的实现:
* - `matchWhere` —— 布尔版(自己的 `$and/$or/$not` 分支 + `matchField`);
* - 三值求值 —— 为 UNKNOWN 传播而新增。
* 两者对**字段级** `$or` / `$not` 的处理不同,于是同一条 SQL 的语义取决于调用点:
* - `{ s: { $not: { $like: 'x' } } }` 经布尔取反会把 NULL 行判真;
* - `{ n: { $or: [...] } }``NOT BETWEEN` 生成)经布尔分支在 NULL 行上判假;
* - 更严重的是字段级 `$or` 递归回了"**where 子句级**"求值器:
* `{ n: { $or: [ { $lt: 1 }, { $gt: 2 } ] } }` 里的 `{ $lt: 1 }` 被当成
* "查询字段 `$lt`",于是每一行都求值 UNKNOWN → `NOT BETWEEN` 恒空集。
*
* 现在只有一条代码路径:**一个递归求值器**,同时理解 where 子句级(键是列名或
* 逻辑连接词)与操作符级(键是 `$gt` 之类)。区别由**位置**参数承载,
* 而不是由另一个函数承载:
* - `evalWhere(ctx, where)` —— where 子句(`$and`/`$or` 子项是 where 子句)
* - `evalOperatorObject(ctx, ...)` —— 操作符对象(`$and`/`$or` 子项是操作符对象)
* 因此 `$or` 的两种含义都在同一个函数里显式分派,不可能再漂移。
*
* `matchWhere` 保留为对外入口,语义定义为"是否保留该行" = 三值结果恰为 TRUE。
*/ */
import type { WhereCondition, OrderBy } from '../constants'; import type { WhereCondition, OrderBy } from '../constants';
import { DatabaseError } from '../constants'; import { DatabaseError } from '../constants';
import {
SQL_LOGICAL,
type SqlTruth,
sqlCompare,
sqlCompareOrder,
sqlIn,
sqlNot,
sqlAnd,
sqlOr,
isSqlNull,
isUnresolved,
UNRESOLVED,
} from './sql-compare';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// LIKE 正则缓存 // LIKE 正则缓存
@@ -28,169 +60,326 @@ function compileLikeRegex(pattern: string): RegExp {
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// WHERE 匹配(顶层入口) // 求值上下文
// ---------------------------------------------------------------------------
/** 求值选项(对外 API 保持既有形状) */
export interface MatchOptions {
/**
* 是否解析 `$col` 列引用(关联子查询 / JOIN ON 的列对列比较)。
*
* 引擎层(`matchWhere` 直通调用)没有外层行上下文,必须**不**解析:
* 此时 `$col` 求值为 UNRESOLVED(比较 → UNKNOWN,行被排除),
* 而不是抛"未知操作符",也不是静默判真。
*/
$col?: boolean;
}
/**
* 内部求值上下文。
*
* `row` 是**当前行**`$col: 'y'` 表示"与当前行的 y 列比较"。
* v0.8.0 之前这个行上下文藏在 `matchField` 的闭包里,字段级 `$or` 递归回
* where 级求值器时被丢弃,导致 `$col` 只能靠 `options.$col` 开关 + 行参数
* 隐式传递 —— 现在它显式随上下文下传,任何深度的嵌套求值都不会丢。
*/
interface EvalContext {
row: Record<string, unknown>;
options: MatchOptions;
}
// ---------------------------------------------------------------------------
// WHERE 匹配(对外入口)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* v0.7.4: 检测 WHERE 中未解析的子查询/列引用标记($subquery / $col / $exists)。 * 匹配完整 WHERE 条件(布尔口径:仅 TRUE 保留该行)。
* QueryBuilder 等直通引擎的写路径(update/delete)不经 Executor 解析子查询, *
* 引擎层 matchWhere 对未解析标记恒 false → 静默影响 0 行。
* 写路径预检阶段显式拒绝;SELECT 路径由 Executor 解析(不调用本函数)。
*/
export function containsUnresolvedSubqueries(where: WhereCondition | undefined): boolean {
if (!where) return false;
for (const [k, v] of Object.entries(where)) {
if (k === '$and' || k === '$or') {
if ((v as WhereCondition[]).some((sub) => containsUnresolvedSubqueries(sub))) return true;
continue;
}
if (k === '$not') {
if (containsUnresolvedSubqueries(v as WhereCondition)) return true;
continue;
}
if (k === '$exists') return true;
if (typeof v === 'object' && v !== null && !Array.isArray(v)) {
for (const [, operand] of Object.entries(v as Record<string, unknown>)) {
if (typeof operand === 'object' && operand !== null) {
const ops = operand as Record<string, unknown>;
if ('$subquery' in ops || '$col' in ops) return true;
}
}
}
}
return false;
}
/**
* 匹配完整 WHERE 条件
* @param row 当前数据行 * @param row 当前数据行
* @param where WHERE 条件对象 * @param where WHERE 条件对象
* @param options.$col 是否启用 $col 列引用解析 * @param options `$col` 是否启用列引用解析
*/ */
export function matchWhere( export function matchWhere(
row: Record<string, unknown>, row: Record<string, unknown>,
where: WhereCondition, where: WhereCondition,
options: { $col?: boolean } = {}, options: MatchOptions = {},
): boolean { for (const [field, condition] of Object.entries(where)) { ): boolean {
// 顶层 $caseResultv0.3.2):由 Executor 对 CASE WHEN 表达式逐行求值后产生 return evalWhere({ row, options }, where) === SQL_LOGICAL.TRUE;
if (field === '$caseResult') { }
if (condition !== true) return false;
/**
* 三值版本:供需要区分 UNKNOWN 与 FALSE 的调用方使用
* JOIN 外连接判定、`NOT IN` 子查询等)。
*/
export function matchWhereThreeValued(
row: Record<string, unknown>,
where: WhereCondition,
options: MatchOptions = {},
): SqlTruth {
return evalWhere({ row, options }, where);
}
/**
* v0.7.4: 检测 WHERE 中未解析的子查询/列引用标记($subquery / $col / $exists)。
*
* QueryBuilder 等直通引擎的写路径(update/delete)不经 Executor 解析子查询,
* 写路径预检阶段据此显式拒绝(否则这些标记在引擎层恒 UNKNOWN → 静默影响 0 行)。
*/
export function containsUnresolvedSubqueries(where: WhereCondition | undefined): boolean {
if (!where) return false;
for (const [k, v] of Object.entries(where)) {
if (isLogicalKey(k)) {
const subs = (Array.isArray(v) ? v : [v]) as WhereCondition[];
if (subs.some((sub) => containsUnresolvedSubqueries(sub))) return true;
continue; continue;
} }
// 顶层 $existsv0.3.0):由 Executor.resolveSubqueries 解析为 boolean if (k === '$exists') return true;
if (field === '$exists') { if (isPlainObject(v) && operatorObjectHasUnresolved(v)) return true;
if (condition !== true) return false; }
return false;
}
/** 递归检测操作符对象里是否存在未解析引用(含 `$and`/`$or`/`$not` 内部) */
function operatorObjectHasUnresolved(ops: Record<string, unknown>): boolean {
for (const [op, operand] of Object.entries(ops)) {
if (op === '$and' || op === '$or') {
const subs = (Array.isArray(operand) ? operand : [operand]) as Record<string, unknown>[];
if (subs.some((sub) => isPlainObject(sub) && operatorObjectHasUnresolved(sub))) return true;
continue; continue;
} }
// 顶层 $and if (op === '$not') {
if (field === '$and') { if (isPlainObject(operand) && operatorObjectHasUnresolved(operand)) return true;
const subs = condition as WhereCondition[];
if (!subs.every((sub) => matchWhere(row, sub, options))) return false;
continue; continue;
} }
// 顶层 $or if (op === '$subquery' || op === '$col') return true;
if (field === '$or') { if (isPlainObject(operand) && ('$subquery' in operand || '$col' in operand)) return true;
const subs = condition as WhereCondition[];
if (!subs.some((sub) => matchWhere(row, sub, options))) return false;
continue;
} }
// 顶层 $notv0.3.2 修复:NOT (expr) 生成的 { $not: inner } return false;
if (field === '$not') {
if (matchWhere(row, condition as WhereCondition, options)) return false;
continue;
}
if (!matchField(row[field], condition, row, options)) return false;
}
return true;
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 字段匹配 // 统一递归求值器
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function matchField( /** 逻辑连接词(where 子句级) */
function isLogicalKey(key: string): boolean {
return key === '$and' || key === '$or' || key === '$not';
}
/** 纯对象(非 null、非数组) */
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* 求值一个 WHERE 子句(where 级)。
*
* 顶层键要么是逻辑连接词,要么是列名:
* - `$and: [where...]` / `$or: [where...]` —— 子项是**完整 where 子句**
* - `$not: where` —— 子项是完整 where 子句;
* - `$exists: boolean` —— 关联 EXISTS 解析结果标记;
* - `$caseResult` —— CASE WHEN 解析结果标记;
* - 其余键 = 列名,其值是**操作符对象**或裸值(裸值等价 `$eq`)。
*/
function evalWhere(ctx: EvalContext, where: WhereCondition): SqlTruth {
let acc: SqlTruth = SQL_LOGICAL.TRUE;
for (const [key, condition] of Object.entries(where)) {
let one: SqlTruth;
if (key === '$and') {
one = evalWhereLogical(ctx, condition, 'and');
} else if (key === '$or') {
one = evalWhereLogical(ctx, condition, 'or');
} else if (key === '$not') {
one = sqlNot(evalWhere(ctx, condition as WhereCondition));
} else if (key === '$exists' || key === '$caseResult') {
one = condition === true ? SQL_LOGICAL.TRUE : SQL_LOGICAL.FALSE;
} else {
one = evalValueCondition(ctx, resolveField(ctx, key), condition);
}
acc = sqlAnd(acc, one);
if (acc === SQL_LOGICAL.FALSE) return SQL_LOGICAL.FALSE;
}
return acc;
}
/** `$and` / `$or`where 级):子项是完整 where 子句 */
function evalWhereLogical(ctx: EvalContext, condition: unknown, kind: 'and' | 'or'): SqlTruth {
const subs = (Array.isArray(condition) ? condition : [condition]) as WhereCondition[];
let acc: SqlTruth = kind === 'and' ? SQL_LOGICAL.TRUE : SQL_LOGICAL.FALSE;
for (const sub of subs) {
const one = evalWhere(ctx, sub);
acc = kind === 'and' ? sqlAnd(acc, one) : sqlOr(acc, one);
// FALSE 在 AND 下、TRUE 在 OR 下都无法被后续子项改变
if (kind === 'and' && acc === SQL_LOGICAL.FALSE) return SQL_LOGICAL.FALSE;
if (kind === 'or' && acc === SQL_LOGICAL.TRUE) return SQL_LOGICAL.TRUE;
}
return acc;
}
/**
* 取字段值。
*
* 行里的键可能是 `t.id` 形式(JOIN / 子查询合并行),而 WHERE 键可能写作
* `id`。既有行为是"精确命中优先,否则回退到唯一的后缀匹配";此处保持一致,
* 但**不再**像旧实现那样在多个别名同名字段间静默取第一个 —— 歧义由 Executor
* 的列解析阶段(`bindColumnRefs` / `assertProjectionColumnsExist`)负责报错。
*/
function resolveField(ctx: EvalContext, field: string): unknown {
if (field in ctx.row) return ctx.row[field];
let found: unknown = UNRESOLVED;
let hits = 0;
for (const key of Object.keys(ctx.row)) {
if (key.endsWith(`.${field}`)) {
found = ctx.row[key];
hits += 1;
}
}
if (hits === 1) return found;
if (hits > 1) return UNRESOLVED;
return UNRESOLVED;
}
/**
* 求值"某个值是否满足条件"—— 字段条件与嵌套操作数条件共用的核心。
*
* 位置无关性是本模块的关键:`{ $or: [ { $gt: 1 } ] }` 无论出现在字段位置
* 还是顶层(历史单元测试的写法),子项都按**操作数条件**求值 —— 因为判别式
* 相同:键以 `$` 开头且不是逻辑连接词,就是操作符;否则是列名。
*/
function evalValueCondition(
ctx: EvalContext,
value: unknown, value: unknown,
condition: unknown, condition: unknown,
row: Record<string, unknown>, ): SqlTruth {
options: { $col?: boolean }, if (!isPlainObject(condition)) {
): boolean { // 裸值 = `$eq`;数组 = `$in` 列表(与既有 Mongo 风格 WHERE 兼容)
// 嵌套 $and if (Array.isArray(condition)) return sqlIn(value, condition);
if (typeof condition === 'object' && condition !== null && '$and' in (condition as Record<string, unknown>)) { return compareEquality(value, condition);
const subs = (condition as Record<string, unknown>).$and as WhereCondition[];
return subs.every((sub) => matchWhere(row, sub, options));
} }
// 嵌套 $or return evalOperatorObject(ctx, value, condition);
if (typeof condition === 'object' && condition !== null && '$or' in (condition as Record<string, unknown>)) {
const subs = (condition as Record<string, unknown>).$or as WhereCondition[];
return subs.some((sub) => matchWhere(row, sub, options));
}
// $not
if (typeof condition === 'object' && condition !== null && '$not' in (condition as Record<string, unknown>)) {
return !matchField(value, (condition as Record<string, unknown>).$not, row, options);
}
// 简单值 => $eq
if (typeof condition !== 'object' || condition === null || Array.isArray(condition)) {
return value === condition;
} }
const ops = condition as Record<string, unknown>; /**
* 求值一个操作符对象(`{ $gt: 1 }` / `{ $and: [...] }` / `{ $col: 'y' }` …)。
// $col 简写: { $col: name } === { $eq: { $col: name } }(仅 JOIN ON 场景) *
if (options.$col && '$col' in ops && Object.keys(ops).length === 1) { * `$and` / `$or` 的每个子项按"操作数条件"递归 —— 这正是 `NOT BETWEEN`
return value === row[ops.$col as string]; * 生成的 `{ n: { $or: [ { $lt: 1 }, { $gt: 2 } ] } }` 能正确求值的原因。
* 若子项实际是**完整 where 子句**(键是列名),则转交 `evalWhere`
* 与当前行合并后求值(`$col` 上下文因此仍然有效)。
*/
function evalOperatorObject(
ctx: EvalContext,
value: unknown,
ops: Record<string, unknown>,
): SqlTruth {
// 纯列引用:{ $col: 'y' } = 与当前行的 y 列比较
const keys = Object.keys(ops);
if (keys.length === 1 && keys[0] === '$col' && ctx.options.$col) {
return compareEquality(value, resolveField(ctx, String(ops.$col)));
} }
// v0.8.0 根治:**未提供 $col 上下文**时,含列引用/未解析子查询的条件必须 let acc: SqlTruth = SQL_LOGICAL.TRUE;
// "放行"而不是"判假"。
//
// 背景:`t.x = t.y` 解析为 `{ x: { $eq: { $col: 'y' } } }`,执行路径是
// engines.find(plan with 原始 where) ← 引擎层 post-index matchWhere(无 $col 上下文)
// → executor.filterCorrelated(rows, stmt.where, { $col: true }) ← 真正的判定
// 引擎层那一遍只是"索引命中后的安全过滤"(子集语义)。若它把 `$col` 当成普通对象
// 比较,任何行都不匹配 → 返回空集,executor 拿到空数组、逐行求值根本没机会执行
// → **静默空结果**(实测 `SELECT id FROM t WHERE t.x = t.y` 返回 [])。
//
// 语义上这是安全的:引擎层的过滤只允许"缩小候选集",而这里选择不过滤;
// 最终判定始终由带 $col 上下文的 executor 完成(无法解析时由它抛 QUERY_ERROR)。
if (!options.$col) {
if ('$col' in ops && Object.keys(ops).length === 1) return true;
for (const [, operand] of Object.entries(ops)) {
if (typeof operand === 'object' && operand !== null && !Array.isArray(operand)) {
const inner = operand as Record<string, unknown>;
if ('$col' in inner || '$subquery' in inner) return true;
}
}
}
// 遍历操作符
for (const [op, operand] of Object.entries(ops)) { for (const [op, operand] of Object.entries(ops)) {
let actualOperand = operand; let one: SqlTruth;
if (op === '$and') {
// $col 列引用解析 one = evalOperatorLogical(ctx, value, operand, 'and');
if (options.$col && typeof operand === 'object' && operand !== null && '$col' in (operand as Record<string, unknown>)) { } else if (op === '$or') {
actualOperand = row[(operand as Record<string, unknown>).$col as string]; one = evalOperatorLogical(ctx, value, operand, 'or');
} else if (op === '$not') {
one = sqlNot(evalOperandSlot(ctx, value, operand));
} else {
one = evalOperator(ctx, value, op, operand);
}
acc = sqlAnd(acc, one);
if (acc === SQL_LOGICAL.FALSE) return SQL_LOGICAL.FALSE;
}
return acc;
} }
if (!matchOperator(value, op, actualOperand)) return false; /** `$and` / `$or`(操作符级):子项是操作数条件(操作符对象或裸值) */
function evalOperatorLogical(
ctx: EvalContext,
value: unknown,
condition: unknown,
kind: 'and' | 'or',
): SqlTruth {
const items = Array.isArray(condition) ? condition : [condition];
let acc: SqlTruth = kind === 'and' ? SQL_LOGICAL.TRUE : SQL_LOGICAL.FALSE;
for (const item of items) {
const one = evalOperandSlot(ctx, value, item);
acc = kind === 'and' ? sqlAnd(acc, one) : sqlOr(acc, one);
if (kind === 'and' && acc === SQL_LOGICAL.FALSE) return SQL_LOGICAL.FALSE;
if (kind === 'or' && acc === SQL_LOGICAL.TRUE) return SQL_LOGICAL.TRUE;
} }
return true; return acc;
} }
// --------------------------------------------------------------------------- /**
// 操作符匹配 * 单个嵌套子项(`$and`/`$or`/`$not` 的操作数)的求值。
// --------------------------------------------------------------------------- *
* 两种合法形态,按**键的形状**判别,不做猜测性兜底:
* - 含非 `$` 开头(或逻辑连接词)的键 → 完整 where 子句,交由 `evalWhere`
* - 其余 → 操作符对象,交由 `evalOperatorObject`。
*
* 注意 `$not` 的子项**不会**走到这里被判为 where 子句:`evalOperatorObject`
* 对 `$not` 直接调用本函数,而 `{ $gte: 1 }` 全部键以 `$` 开头 → 操作符对象 ✓。
* 若调用方写成 `{ $not: { n: { $gte: 1 } } }`(字段级 `$not` 包了 where 子句),
* 则按 where 子句解释 —— 这正是 `evalNestedSlot` 的判别分支,语义为
* "NOT (该行满足 n >= 1)",与顶层 `$not` 一致。
*/
function evalOperandSlot(ctx: EvalContext, value: unknown, item: unknown): SqlTruth {
if (!isPlainObject(item)) return evalValueCondition(ctx, value, item);
const isWhereClause = Object.keys(item).some(
(k) => !k.startsWith('$') || isLogicalKey(k) || k === '$exists' || k === '$caseResult',
);
return isWhereClause ? evalWhere(ctx, item as WhereCondition) : evalOperatorObject(ctx, value, item);
}
function matchOperator(value: unknown, op: string, operand: unknown): boolean { /**
* 操作符求值(三值语义)。
*
* - 比较类操作数含 NULL → UNKNOWN(此前 `$eq: null` 命中 NULL 行、`$ne: null`
* 命中所有非 NULL 行,两者都不符合 SQL 标准);
* - `$in` / `$nin` 用 `sqlIn` 的列表语义(列表含 NULL → 未命中时为 UNKNOWN);
* - `$like` 对 NULL 操作数返回 UNKNOWN`String(null)` 会得到 "null" 去匹配,
* 属于静默错值);
* - `$isNull` / `$isNotNull` 是**谓词**,直接对 NULL 求值,不走比较。
*/
function evalOperator(
ctx: EvalContext,
value: unknown,
op: string,
operand: unknown,
): SqlTruth {
const actual = resolveOperand(ctx, operand);
switch (op) { switch (op) {
case '$eq': return value === operand; case '$eq': return compareEquality(value, actual);
case '$ne': return value !== operand; case '$ne': return sqlNot(compareEquality(value, actual));
case '$gt': return (value as number) > (operand as number); case '$gt': return compareOrdered(value, actual, (c) => c > 0);
case '$gte': return (value as number) >= (operand as number); case '$gte': return compareOrdered(value, actual, (c) => c >= 0);
case '$lt': return (value as number) < (operand as number); case '$lt': return compareOrdered(value, actual, (c) => c < 0);
case '$lte': return (value as number) <= (operand as number); case '$lte': return compareOrdered(value, actual, (c) => c <= 0);
case '$in': return Array.isArray(operand) && operand.includes(value); case '$in': return Array.isArray(actual) ? sqlIn(value, actual) : SQL_LOGICAL.FALSE;
case '$nin': return Array.isArray(operand) && !operand.includes(value); case '$nin': return Array.isArray(actual) ? sqlNot(sqlIn(value, actual)) : SQL_LOGICAL.FALSE;
case '$like': return compileLikeRegex(String(operand)).test(String(value)); case '$like': {
if (isSqlNull(value) || isSqlNull(actual) || isUnresolved(value) || isUnresolved(actual)) {
return SQL_LOGICAL.UNKNOWN;
}
return compileLikeRegex(String(actual)).test(String(value))
? SQL_LOGICAL.TRUE
: SQL_LOGICAL.FALSE;
}
// 谓词(不参与三值比较)
case '$isNull': return isSqlNull(value) ? SQL_LOGICAL.TRUE : SQL_LOGICAL.FALSE;
case '$isNotNull': return isSqlNull(value) ? SQL_LOGICAL.FALSE : SQL_LOGICAL.TRUE;
// `$col` 出现在操作符位置但未启用列上下文 → 无法求值
case '$col': return ctx.options.$col
? compareEquality(value, resolveField(ctx, String(operand)))
: SQL_LOGICAL.UNKNOWN;
// `$subquery` 必须由 Executor 先行解析(`resolveSubqueries`)。
// 走到这里说明调用方跳过了 Executor:返回 UNKNOWN 让该行被排除,
// 而不是静默当 NULL 比较。
case '$subquery': return SQL_LOGICAL.UNKNOWN;
// v0.7.2: 未知操作符显式报错 —— 此前静默返回 true(所有行匹配), // v0.7.2: 未知操作符显式报错 —— 此前静默返回 true(所有行匹配),
// 拼错操作符(如 $betwen)时过滤形同虚设且无任何提示 // 拼错操作符(如 $betwen)时过滤形同虚设且无任何提示
default: default:
@@ -198,6 +387,48 @@ function matchOperator(value: unknown, op: string, operand: unknown): boolean {
} }
} }
/**
* 解析操作数槽位中的引用标记。
*
* - `{ $col: 'y' }` → 当前行的 y 列值(仅在 `options.$col` 启用时);
* - `{ $subquery: [...] }` → UNRESOLVED,交由上层识别为"未解析"。
*
* 这两种形态都是**对象**,而非"恰好等于某个值",所以必须在这里显式解引用,
* 否则 `sqlCompare(5, { $col: 'y' })` 会按不可比返回 UNKNOWN —— 恰好也是
* UNKNOWN,但那会掩盖"调用方忘了传 `$col`"这一真正的配置错误。
*/
function resolveOperand(ctx: EvalContext, operand: unknown): unknown {
if (!isPlainObject(operand)) return operand;
const keys = Object.keys(operand);
if (keys.length !== 1) return operand;
if (keys[0] === '$col') {
return ctx.options.$col ? resolveField(ctx, String(operand.$col)) : UNRESOLVED;
}
if (keys[0] === '$subquery') return UNRESOLVED;
return operand;
}
/** 相等比较(三值) */
function compareEquality(value: unknown, operand: unknown): SqlTruth {
const truth = sqlCompare(value, operand);
if (truth === SQL_LOGICAL.TRUE) return SQL_LOGICAL.TRUE;
if (isSqlNull(value) || isSqlNull(operand) || isUnresolved(value) || isUnresolved(operand)) {
return SQL_LOGICAL.UNKNOWN;
}
return SQL_LOGICAL.FALSE;
}
/** 有序比较(三值):任一操作数 NULL/未解析/不可比 → UNKNOWN */
function compareOrdered(
value: unknown,
operand: unknown,
accept: (cmp: number) => boolean,
): SqlTruth {
const ord = sqlCompareOrder(value, operand);
if (ord === null) return SQL_LOGICAL.UNKNOWN;
return accept(ord) ? SQL_LOGICAL.TRUE : SQL_LOGICAL.FALSE;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// 排序 // 排序
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+22 -2
View File
@@ -898,7 +898,11 @@ export class Parser {
if (isNot) this.nextToken(); if (isNot) this.nextToken();
this.expect(TokenType.NULL); this.expect(TokenType.NULL);
const result: WhereCondition = this.newColumnMap<FieldCondition>() as WhereCondition; const result: WhereCondition = this.newColumnMap<FieldCondition>() as WhereCondition;
result[column] = isNot ? { $ne: null } : { $eq: null }; // v0.8.0 三值语义:IS NULL / IS NOT NULL 是**谓词**,不是等值比较。
// 此前生成为 { $eq: null } / { $ne: null } —— 在 SQL 标准里
// `x = NULL` 恒为 UNKNOWN(不保留任何行),而 IS NULL 要保留 NULL 行。
// 两者语义不同,必须用不同标记(见 query/sql-compare.ts 的谓词说明)。
result[column] = isNot ? { $isNotNull: true } : { $isNull: true };
return result; return result;
} }
@@ -908,6 +912,11 @@ export class Parser {
const low = this.parseValue(); const low = this.parseValue();
this.expect(TokenType.AND); this.expect(TokenType.AND);
const high = this.parseValue(); const high = this.parseValue();
// v0.8.0 根治:BETWEEN 必须是**范围**条件。
// 此前把同一个对象同时当成"操作符对象"和"操作数"传给 `$eq`
// `{ $eq: { $gte, $lte } }`),matchOperator 的 `$eq` 收到一个对象再去比较,
// 结果只对"值恰好等于该对象"的行成立 —— 实测 `WHERE n BETWEEN 1 AND 2`
// 在 (1,2,NULL,3) 上只返回 1 行(应 2 行),静默错值。
const result: WhereCondition = this.newColumnMap<FieldCondition>() as WhereCondition; const result: WhereCondition = this.newColumnMap<FieldCondition>() as WhereCondition;
result[column] = { $gte: low, $lte: high }; result[column] = { $gte: low, $lte: high };
return result; return result;
@@ -921,7 +930,18 @@ export class Parser {
this.expect(TokenType.AND); this.expect(TokenType.AND);
const high = this.parseValue(); const high = this.parseValue();
const result: WhereCondition = this.newColumnMap<FieldCondition>() as WhereCondition; const result: WhereCondition = this.newColumnMap<FieldCondition>() as WhereCondition;
result[column] = { $not: { $gte: low, $lte: high } }; // v0.8.0NOT BETWEEN ≡ (x < low) OR (x > high),直接展开为字段级 `$or`。
//
// 此前生成 `{ $not: { $gte, $lte } }`。这**曾经**恒为空集:字段级 `$not`
// 把内层对象当"单个条件对象"key `$gte`/`$lte` 被当成列名去取
// `row['$gte']` → undefined → 整条恒 UNKNOWN → 取反仍 UNKNOWN → 全部排除。
//
// 求值器已修正(内层按操作符对象解释),但这里仍选择展开为 `$or`:
// - `$or` 的三值行为(含 NULL 时 UNKNOWN)是显式可读的;
// - 避免依赖"$not 作用于比较"与"$not 作用于谓词"(如 `$isNull`)的差别。
// 两条路径都有测试锁定(tests/v080-sql-three-valued.test.ts 与
// tests/sql/where-matcher.test.ts 的 `$not` 用例)。
result[column] = { $or: [{ $lt: low } as never, { $gt: high } as never] as never };
return result; return result;
} }
+15 -2
View File
@@ -74,12 +74,17 @@ describe('Parser 边缘场景', () => {
it('WHERE IS NULL', () => { it('WHERE IS NULL', () => {
const ast = asSelect(parse('SELECT * FROM users WHERE bio 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', () => { it('WHERE IS NOT NULL', () => {
const ast = asSelect(parse('SELECT * FROM users WHERE bio 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', () => { it('WHERE NOT', () => {
@@ -235,6 +240,14 @@ describe('Parser 边缘场景', () => {
it('NULL 值', () => { it('NULL 值', () => {
const ast = asSelect(parse('SELECT * FROM users WHERE bio IS 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 } }); 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', () => { describe('v0.6.2 — Aria 索引列 IS NULL', () => {
it('引擎层 $eq: null 返回 null 行', async () => { it('引擎层 $isNull 命中 null 行', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' }); const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1); await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', { await eng.createTable(createSchema('t', {
@@ -216,12 +216,28 @@ describe('v0.6.2 — Aria 索引列 IS NULL', () => {
email: { type: 'string', index: 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' }]); 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).toHaveLength(1);
expect(rows[0].id).toBe('2'); expect(rows[0].id).toBe('2');
await eng.close(); 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 () => { it('SQL 层 IS NULL / IS NOT NULL 正确', async () => {
const db = new MetonaSqlark({ name: uniqueDB(), mode: 'aria', diskEngine: 'memory' }); const db = new MetonaSqlark({ name: uniqueDB(), mode: 'aria', diskEngine: 'memory' });
await db.init(); await db.init();
@@ -239,7 +255,7 @@ describe('v0.6.2 — Aria 索引列 IS NULL', () => {
await db.close(); await db.close();
}); });
it('IN 列表含 null 不走索引(不漏 null 行)', async () => { it('IN 列表含 null 不走索引(不漏可匹配行)', async () => {
const eng = new AriaEngine({ storageBackend: 'memory' }); const eng = new AriaEngine({ storageBackend: 'memory' });
await eng.open(uniqueDB(), 1); await eng.open(uniqueDB(), 1);
await eng.createTable(createSchema('t', { await eng.createTable(createSchema('t', {
@@ -247,8 +263,25 @@ describe('v0.6.2 — Aria 索引列 IS NULL', () => {
email: { type: 'string', index: true }, email: { type: 'string', index: true },
})); }));
await eng.insert('t', [{ id: '1', email: 'a@b.c' }, { id: '2', email: null }]); 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'] } } }); 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(); await eng.close();
}); });
}); });
+20 -2
View File
@@ -38,18 +38,36 @@ describe('v0.7.3: 索引列 IS NULL(三引擎对齐)', () => {
test.each([ test.each([
['memory', { mode: 'memory' } as const], ['memory', { mode: 'memory' } as const],
['disk', { mode: 'disk', diskEngine: '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 }); const db = await MetonaSqlark.create({ name: `v073-isnull-qb-${_label}`, ...cfg });
await db.defineTable('users', { await db.defineTable('users', {
id: { type: 'string', primaryKey: true }, id: { type: 'string', primaryKey: true },
email: { type: 'string', unique: true }, email: { type: 'string', unique: true },
}); });
await db.query("INSERT INTO users VALUES ('1', NULL), ('2', 'a@b.c')"); 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); expect(rows).toHaveLength(1);
await db.close(); 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 () => { test('aria: IS NULL 回归护栏(v0.6.2 已修)', async () => {
const db = await MetonaSqlark.create({ name: 'v073-isnull-aria', mode: 'aria', diskEngine: 'memory' }); const db = await MetonaSqlark.create({ name: 'v073-isnull-aria', mode: 'aria', diskEngine: 'memory' });
await db.defineTable('users', { 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']);
});
});
}
});