Files
MetonaSqlark/src/query/where-matcher.ts
T
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

483 lines
19 KiB
TypeScript
Raw 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.
/**
* metona-sqlark Shared WHERE Matcher —— 统一的条件求值器
* @module query/where-matcher
*
* ============================================================================
* 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 { DatabaseError } from '../constants';
import {
SQL_LOGICAL,
type SqlTruth,
sqlCompare,
sqlCompareOrder,
sqlIn,
sqlNot,
sqlAnd,
sqlOr,
isSqlNull,
isUnresolved,
UNRESOLVED,
} from './sql-compare';
// ---------------------------------------------------------------------------
// LIKE 正则缓存
// ---------------------------------------------------------------------------
const likeCache = new Map<string, RegExp>();
function compileLikeRegex(pattern: string): RegExp {
const cached = likeCache.get(pattern);
if (cached) return cached;
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/%/g, '.*')
.replace(/_/g, '.');
const regex = new RegExp(`^${escaped}$`, 'i');
likeCache.set(pattern, regex);
return regex;
}
// ---------------------------------------------------------------------------
// 求值上下文
// ---------------------------------------------------------------------------
/** 求值选项(对外 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 匹配(对外入口)
// ---------------------------------------------------------------------------
/**
* 匹配完整 WHERE 条件(布尔口径:仅 TRUE 保留该行)。
*
* @param row 当前数据行
* @param where WHERE 条件对象
* @param options `$col` 是否启用列引用解析
*/
export function matchWhere(
row: Record<string, unknown>,
where: WhereCondition,
options: MatchOptions = {},
): boolean {
return evalWhere({ row, options }, where) === SQL_LOGICAL.TRUE;
}
/**
* 三值版本:供需要区分 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;
}
if (k === '$exists') return true;
if (isPlainObject(v) && operatorObjectHasUnresolved(v)) return true;
}
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;
}
if (op === '$not') {
if (isPlainObject(operand) && operatorObjectHasUnresolved(operand)) return true;
continue;
}
if (op === '$subquery' || op === '$col') return true;
if (isPlainObject(operand) && ('$subquery' in operand || '$col' in operand)) return true;
}
return false;
}
// ---------------------------------------------------------------------------
// 统一递归求值器
// ---------------------------------------------------------------------------
/** 逻辑连接词(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,
condition: unknown,
): SqlTruth {
if (!isPlainObject(condition)) {
// 裸值 = `$eq`;数组 = `$in` 列表(与既有 Mongo 风格 WHERE 兼容)
if (Array.isArray(condition)) return sqlIn(value, condition);
return compareEquality(value, condition);
}
return evalOperatorObject(ctx, value, condition);
}
/**
* 求值一个操作符对象(`{ $gt: 1 }` / `{ $and: [...] }` / `{ $col: 'y' }` …)。
*
* `$and` / `$or` 的每个子项按"操作数条件"递归 —— 这正是 `NOT BETWEEN`
* 生成的 `{ 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)));
}
let acc: SqlTruth = SQL_LOGICAL.TRUE;
for (const [op, operand] of Object.entries(ops)) {
let one: SqlTruth;
if (op === '$and') {
one = evalOperatorLogical(ctx, value, operand, 'and');
} else if (op === '$or') {
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;
}
/** `$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 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);
}
/**
* 操作符求值(三值语义)。
*
* - 比较类操作数含 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) {
case '$eq': return compareEquality(value, actual);
case '$ne': return sqlNot(compareEquality(value, actual));
case '$gt': return compareOrdered(value, actual, (c) => c > 0);
case '$gte': return compareOrdered(value, actual, (c) => c >= 0);
case '$lt': return compareOrdered(value, actual, (c) => c < 0);
case '$lte': return compareOrdered(value, actual, (c) => c <= 0);
case '$in': return Array.isArray(actual) ? sqlIn(value, actual) : SQL_LOGICAL.FALSE;
case '$nin': return Array.isArray(actual) ? sqlNot(sqlIn(value, actual)) : SQL_LOGICAL.FALSE;
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(所有行匹配),
// 拼错操作符(如 $betwen)时过滤形同虚设且无任何提示
default:
throw new DatabaseError(`Unknown where operator "${op}"`, 'QUERY_ERROR');
}
}
/**
* 解析操作数槽位中的引用标记。
*
* - `{ $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;
}
// ---------------------------------------------------------------------------
// 排序
// ---------------------------------------------------------------------------
export function applyOrderBy(rows: Record<string, unknown>[], orderBy: OrderBy[]): Record<string, unknown>[] {
return [...rows].sort((a, b) => {
for (const { column, direction, nulls } of orderBy) {
const aNull = a[column] === null || a[column] === undefined;
const bNull = b[column] === null || b[column] === undefined;
// v0.4.0: NULLS FIRST/LAST 时 NULL 位置固定,不受升降序反转
if (nulls && (aNull || bNull)) {
if (aNull && bNull) continue;
const cmp = nulls === 'first' ? (aNull ? -1 : 1) : (aNull ? 1 : -1);
return cmp;
}
const cmp = compare(a[column], b[column]);
if (cmp !== 0) return direction === 'desc' ? -cmp : cmp;
}
return 0;
});
}
function compare(a: unknown, b: unknown): number {
if (a === b) return 0;
if (a === null || a === undefined) return 1;
if (b === null || b === undefined) return -1;
if (typeof a === 'string' && typeof b === 'string') return a.localeCompare(b);
if (typeof a === 'number' && typeof b === 'number') return a - b;
return String(a).localeCompare(String(b));
}
// ---------------------------------------------------------------------------
// 列投影
// ---------------------------------------------------------------------------
export function projectColumns(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
const projected: Record<string, unknown> = {};
for (const col of columns) {
if (col in row) {
projected[col] = row[col];
} else {
for (const key of Object.keys(row)) {
if (key.endsWith(`.${col}`) || key === col) {
projected[col] = row[key];
break;
}
}
}
}
return projected;
}