diff --git a/src/core.ts b/src/core.ts index a419961..83abbfa 100644 --- a/src/core.ts +++ b/src/core.ts @@ -731,9 +731,18 @@ export class MetonaSqlark { this.ready = false; } - /** 获取底层引擎(含变更通知装饰器) */ + /** + * 获取底层存储引擎。 + * + * v0.8.0:返回**未装饰**的真实引擎。 + * + * 变更通知用的 ChangeNotifierEngine 只是内部接线细节;若把它暴露出去, + * 调用方(以及测试)依赖的引擎特有能力(`lsm`、`secondaryIndexes`、 + * `getDiskEngineType` 等)会被静默隐藏 —— 本项目既有测试与文档都按 + * "getEngine() 就是那个引擎"理解。因此这里保持原语义,装饰器只在 core 内部使用。 + */ getEngine(): IStorageEngine { - return this.engine; + return this.unwrapEngine(); } /** diff --git a/src/query/executor.ts b/src/query/executor.ts index e2a94e0..20fae56 100644 --- a/src/query/executor.ts +++ b/src/query/executor.ts @@ -478,7 +478,10 @@ export class QueryExecutor { // v0.4.0 修复: 关联子查询需要完整外层行(SELECT 列可能不含被 $col 引用的列,如 EXISTS 绑定的主键) plan.columns = ['*']; 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); } else { // 先解析子查询 @@ -1448,27 +1451,94 @@ export class QueryExecutor { 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 = {}; for (const [key, value] of Object.entries(where)) { - if (key === '$and' || key === '$or') { - cleaned[key] = (value as WhereCondition[]).map((sub) => this.stripCorrelatedExists(sub)); + if (key === '$and') { + 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; } if (key === '$not') { - const inner = this.stripCorrelatedExists(value as WhereCondition); - // 剥离后为空 → 条件恒真,删掉该键(避免引擎层执行 NOT(true) 过滤掉所有行) - if (Object.keys(inner).length > 0) cleaned.$not = inner; + const inner = value as WhereCondition; + if (!this.isEngineEvaluable(inner)) continue; + cleaned.$not = this.enginePreFilter(inner); continue; } if (key === '$exists') continue; // 逐行求值时单独处理 if (/^\s*CASE\b/i.test(key)) continue; // v0.3.2: CASE 键逐行求值 + if (this.fieldHasColRef(value) || this.fieldHasSubquery(value)) continue; // $col / 子查询逐行求值 cleaned[key] = value; } 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; + 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)) return true; + } + if (Array.isArray(operand)) { + for (const item of operand) { + if (typeof item === 'object' && item !== null && '$subquery' in (item as Record)) return true; + } + } + } + return false; + } + /** 逐行绑定外层行上下文,求值关联 EXISTS、$col 引用与 CASE WHEN 键 */ private async filterCorrelated( rows: Record[], diff --git a/src/query/sql-compare.ts b/src/query/sql-compare.ts new file mode 100644 index 0000000..317ac12 --- /dev/null +++ b/src/query/sql-compare.ts @@ -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 → UNKNOWN(SQL 标准)。 + * `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; +} + +/** 三值 NOT(UNKNOWN 取反仍为 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; +} diff --git a/src/query/where-matcher.ts b/src/query/where-matcher.ts index d7e277f..54556f7 100644 --- a/src/query/where-matcher.ts +++ b/src/query/where-matcher.ts @@ -1,13 +1,45 @@ /** - * metona-sqlark Shared WHERE Matcher — 统一的条件匹配逻辑 + * metona-sqlark Shared 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 { DatabaseError } from '../constants'; +import { + SQL_LOGICAL, + type SqlTruth, + sqlCompare, + sqlCompareOrder, + sqlIn, + sqlNot, + sqlAnd, + sqlOr, + isSqlNull, + isUnresolved, + UNRESOLVED, +} from './sql-compare'; // --------------------------------------------------------------------------- // LIKE 正则缓存 @@ -28,169 +60,326 @@ function compileLikeRegex(pattern: string): RegExp { } // --------------------------------------------------------------------------- -// WHERE 匹配(顶层入口) +// 求值上下文 // --------------------------------------------------------------------------- -/** - * v0.7.4: 检测 WHERE 中未解析的子查询/列引用标记($subquery / $col / $exists)。 - * 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)) { - if (typeof operand === 'object' && operand !== null) { - const ops = operand as Record; - if ('$subquery' in ops || '$col' in ops) return true; - } - } - } - } - return false; +/** 求值选项(对外 API 保持既有形状) */ +export interface MatchOptions { + /** + * 是否解析 `$col` 列引用(关联子查询 / JOIN ON 的列对列比较)。 + * + * 引擎层(`matchWhere` 直通调用)没有外层行上下文,必须**不**解析: + * 此时 `$col` 求值为 UNRESOLVED(比较 → UNKNOWN,行被排除), + * 而不是抛"未知操作符",也不是静默判真。 + */ + $col?: boolean; } /** - * 匹配完整 WHERE 条件 - * @param row 当前数据行 - * @param where WHERE 条件对象 - * @param options.$col 是否启用 $col 列引用解析 + * 内部求值上下文。 + * + * `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: { $col?: boolean } = {}, -): boolean { for (const [field, condition] of Object.entries(where)) { - // 顶层 $caseResult(v0.3.2):由 Executor 对 CASE WHEN 表达式逐行求值后产生 - if (field === '$caseResult') { - if (condition !== true) return false; + 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; } - // 顶层 $exists(v0.3.0):由 Executor.resolveSubqueries 解析为 boolean - if (field === '$exists') { - if (condition !== true) return false; - continue; - } - // 顶层 $and - if (field === '$and') { - const subs = condition as WhereCondition[]; - if (!subs.every((sub) => matchWhere(row, sub, options))) return false; - continue; - } - // 顶层 $or - if (field === '$or') { - const subs = condition as WhereCondition[]; - if (!subs.some((sub) => matchWhere(row, sub, options))) return false; - continue; - } - // 顶层 $not(v0.3.2 修复:NOT (expr) 生成的 { $not: inner }) - if (field === '$not') { - if (matchWhere(row, condition as WhereCondition, options)) return false; - continue; - } - if (!matchField(row[field], condition, row, options)) return false; + if (k === '$exists') return true; + if (isPlainObject(v) && operatorObjectHasUnresolved(v)) return true; } - 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; } // --------------------------------------------------------------------------- -// 字段匹配 +// 统一递归求值器 // --------------------------------------------------------------------------- -function matchField( +/** 逻辑连接词(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, - row: Record, - options: { $col?: boolean }, -): boolean { - // 嵌套 $and - if (typeof condition === 'object' && condition !== null && '$and' in (condition as Record)) { - const subs = (condition as Record).$and as WhereCondition[]; - return subs.every((sub) => matchWhere(row, sub, options)); +): SqlTruth { + if (!isPlainObject(condition)) { + // 裸值 = `$eq`;数组 = `$in` 列表(与既有 Mongo 风格 WHERE 兼容) + if (Array.isArray(condition)) return sqlIn(value, condition); + return compareEquality(value, condition); } - // 嵌套 $or - if (typeof condition === 'object' && condition !== null && '$or' in (condition as Record)) { - const subs = (condition as Record).$or as WhereCondition[]; - return subs.some((sub) => matchWhere(row, sub, options)); - } - // $not - if (typeof condition === 'object' && condition !== null && '$not' in (condition as Record)) { - return !matchField(value, (condition as Record).$not, row, options); - } - // 简单值 => $eq - if (typeof condition !== 'object' || condition === null || Array.isArray(condition)) { - return value === condition; - } - - const ops = condition as Record; - - // $col 简写: { $col: name } === { $eq: { $col: name } }(仅 JOIN ON 场景) - if (options.$col && '$col' in ops && Object.keys(ops).length === 1) { - return value === row[ops.$col as string]; - } - - // v0.8.0 根治:**未提供 $col 上下文**时,含列引用/未解析子查询的条件必须 - // "放行"而不是"判假"。 - // - // 背景:`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; - if ('$col' in inner || '$subquery' in inner) return true; - } - } - } - - // 遍历操作符 - for (const [op, operand] of Object.entries(ops)) { - let actualOperand = operand; - - // $col 列引用解析 - if (options.$col && typeof operand === 'object' && operand !== null && '$col' in (operand as Record)) { - actualOperand = row[(operand as Record).$col as string]; - } - - if (!matchOperator(value, op, actualOperand)) return false; - } - return true; + 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))); + } -function matchOperator(value: unknown, op: string, operand: unknown): boolean { + 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 value === operand; - case '$ne': return value !== operand; - case '$gt': return (value as number) > (operand as number); - case '$gte': return (value as number) >= (operand as number); - case '$lt': return (value as number) < (operand as number); - case '$lte': return (value as number) <= (operand as number); - case '$in': return Array.isArray(operand) && operand.includes(value); - case '$nin': return Array.isArray(operand) && !operand.includes(value); - case '$like': return compileLikeRegex(String(operand)).test(String(value)); + 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: @@ -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; +} + // --------------------------------------------------------------------------- // 排序 // --------------------------------------------------------------------------- diff --git a/src/sql/parser.ts b/src/sql/parser.ts index 30ef007..f0d6692 100644 --- a/src/sql/parser.ts +++ b/src/sql/parser.ts @@ -898,7 +898,11 @@ export class Parser { if (isNot) this.nextToken(); this.expect(TokenType.NULL); const result: WhereCondition = this.newColumnMap() 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; } @@ -908,6 +912,11 @@ export class Parser { const low = this.parseValue(); this.expect(TokenType.AND); 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() as WhereCondition; result[column] = { $gte: low, $lte: high }; return result; @@ -921,7 +930,18 @@ export class Parser { this.expect(TokenType.AND); const high = this.parseValue(); const result: WhereCondition = this.newColumnMap() as WhereCondition; - result[column] = { $not: { $gte: low, $lte: high } }; + // v0.8.0:NOT 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; } diff --git a/tests/sql/parser.test.ts b/tests/sql/parser.test.ts index 0d96b77..4a134d5 100644 --- a/tests/sql/parser.test.ts +++ b/tests/sql/parser.test.ts @@ -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 } }); }); }); diff --git a/tests/v062-fixes.test.ts b/tests/v062-fixes.test.ts index 65c8d21..e90f2cf 100644 --- a/tests/v062-fixes.test.ts +++ b/tests/v062-fixes.test.ts @@ -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(); }); }); diff --git a/tests/v073-fixes.test.ts b/tests/v073-fixes.test.ts index 4e1d6d0..11ea312 100644 --- a/tests/v073-fixes.test.ts +++ b/tests/v073-fixes.test.ts @@ -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', { diff --git a/tests/v080-sql-three-valued.test.ts b/tests/v080-sql-three-valued.test.ts new file mode 100644 index 0000000..1293164 --- /dev/null +++ b/tests/v080-sql-three-valued.test.ts @@ -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; +} + +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>(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>(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>(await db.query('SELECT id FROM u ORDER BY id')); + expect(survivors.map((r) => r.id)).toEqual(['a', 'c']); + }); + }); + } +});