/** * 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(); 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; options: MatchOptions; } // --------------------------------------------------------------------------- // WHERE 匹配(对外入口) // --------------------------------------------------------------------------- /** * 匹配完整 WHERE 条件(布尔口径:仅 TRUE 保留该行)。 * * @param row 当前数据行 * @param where WHERE 条件对象 * @param options `$col` 是否启用列引用解析 */ export function matchWhere( row: Record, where: WhereCondition, options: MatchOptions = {}, ): boolean { return evalWhere({ row, options }, where) === SQL_LOGICAL.TRUE; } /** * 三值版本:供需要区分 UNKNOWN 与 FALSE 的调用方使用 * (JOIN 外连接判定、`NOT IN` 子查询等)。 */ export function matchWhereThreeValued( row: Record, 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): boolean { for (const [op, operand] of Object.entries(ops)) { if (op === '$and' || op === '$or') { const subs = (Array.isArray(operand) ? operand : [operand]) as Record[]; 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 { 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, ): 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[], orderBy: OrderBy[]): Record[] { 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, columns: string[]): Record { const projected: Record = {}; 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; }