feat(B-4): 统一表达式求值 —— CASE 结构化解析(消除正则切分整类缺陷)

修复前 `parseCaseExpression` 用正则切分 WHEN/THEN/ELSE,不认字符串字面量与嵌套,
实测出三类错误结果:

① **嵌套 CASE 返回字符串残片**
   `CASE WHEN n>10 THEN CASE WHEN n>25 THEN 'huge' ELSE 'big' END ELSE 'small' END`
   → 实测返回 `"big' END ELSE 'small"`(正则把嵌套 CASE 的 ELSE 当成自己的分支
   边界,残片被原样返回给用户)。修复后正确返回 huge/big/small。

② **条件引用不存在的列时静默错值**
   `CASE WHEN nope > 1 THEN 'x' ELSE 'y' END` → 每行都是 'y' 且**无任何报错**
   (`cond = null` 表示"解析失败"→ 静默跳过分支),而同一个 nope 写在 WHERE 里
   会正常抛 COLUMN_NOT_FOUND。修复后统一报 COLUMN_NOT_FOUND。

③ **`GROUP BY CASE ... END` 完全不可用**
   → `COLUMN_NOT_FOUND Unknown column "CASE WHEN n>10 THEN 'big' ELSE 'small' END"`
   (分组把 CASE 原文当成列名)。而"按条件分组"是 SQL 最常见的分析写法之一。
   修复后正常输出 `[{band:'big',c:2},{band:'small',c:2}]`。

实现(新增 `src/query/expression.ts`):
- 复用 `sql/lexer` 的 token 流做**递归下降**(天然支持嵌套;字符串里的
  WHEN/ELSE/THEN 由词法层天然隔离,不可能被当作切分点);
- 条件按源码切片后交给与 WHERE **完全相同**的 `parseWhereCondition` —— 语义同源;
- 结果表达式显式支持字面量/列引用/嵌套 CASE,无法识别的**抛 NOT_SUPPORTED**
  (不再把原文当字符串返回);
- 解析结果记忆化(同一表达式在 N 行上只解析一次);
- 新增 `assertCaseColumnsExist`,在**分组/聚合之前**按 schema 校验 CASE 里引用的列
  (时机很关键:分组会把行替换为"分组键+聚合值",此后任何基于行的校验都会
  误报未知列 —— 这一点在实现中踩到并已修正)。

顺带修掉的**词法层**缺陷:`Token.position` 语义按 token 类型不一致 ——
`readString` 用 `position + 1`(指向引号**之内**),`readIdentifier`/`readNumber`
用 `position - len`(指向首字符)。任何"按 position 切片"的调用方都会对字符串
切错一个字符(实测 `'big'` 被切成 `"big'"`,导致 CASE 全部报 NOT_SUPPORTED)。
现统一为"token 首字符在源码中的下标",并用"从 position 重新词法化应得到同一
token"作为可判定判据加入回归测试。

验证:新增 tests/v080-case-expression.test.ts(54 项:词法层 2 + 解析层 8 +
求值层 5 + 列校验 2 + 四引擎端到端 37),并做**变异验证**:去掉嵌套 CASE 感知后
7 项立即失败(含四引擎的端到端断言),恢复后全绿。
全量 91 套件 / 1807 测试通过;typecheck、lint、build 零错误零告警;dist 已重建。
This commit is contained in:
thzxx
2026-09-15 07:26:20 +08:00
parent bf93ec5251
commit 3983aae426
13 changed files with 2801 additions and 523 deletions
+89
View File
@@ -0,0 +1,89 @@
/**
* metona-sqlark 列引用取值 —— WHERE / 投影 / 聚合 / 表达式**共用**的唯一实现
* @module query/column-value
*
* ============================================================================
* 为什么必须只有一个实现(PLAN-v0.7.5.md 根因 1
* ============================================================================
* "从一行里按名字取一列"曾是四处各写一份的实现,规则各不相同:
*
* | 位置 | 别名前缀 | 后缀回退 | 未知列 |
* |---|---|---|---|
* | `projectColumns`where-matcher | 否 | 唯一后缀 | 静默丢键 |
* | `resolveAliasSource`executor | **否** | 否 | 静默 undefined |
* | 聚合参数(executor | 否 | 否 | **静默计 0**A25 |
* | `matchWhere` 的 `resolveField` | 否 | 唯一后缀(多个则 UNRESOLVED | UNRESOLVED |
*
* 于是 `COUNT(t.n)` 返回 0A25)、`SELECT d.id FROM (...) AS d` 返回空集(A36)、
* CASE 的 THEN 分支引用列名时行为随调用点变化(B-4)。
*
* 现在四处都调用本模块的 `resolveColumnValue`
* - JOIN 行的键是 `alias.col`,单表行的键是 `col` —— 两种形态都要支持;
* - 取不到值时**由调用方**决定是抛错还是返回 UNRESOLVED`strict` 选项),
* 因为"WHERE 里的未解析引用"与"表达式里的未解析引用"需要不同的上层处理。
*/
import { DatabaseError } from '../constants';
import { UNRESOLVED } from './sql-compare';
/** 取值选项 */
export interface ResolveOptions {
/**
* 取不到值时是否抛 `COLUMN_NOT_FOUND`。
* - `true`:用于 GROUP BY / 聚合参数 / 投影等"这里必须有一列"的场景;
* - `false`:返回 `UNRESOLVED` 哨兵,由调用方决定(WHERE 需要据此判 UNKNOWN
* 表达式需要据此抛错,而"行里确实没有该键"与"该列值为 NULL"必须区分)。
*/
strict: boolean;
/** 错误消息中的位置描述(如 'GROUP BY' / 'aggregate COUNT(n)' / 'SELECT list' */
context: string;
}
/**
* 从行里取一个列引用。
*
* 解析顺序(与 ORDER BY 的 `stripAlias` 语义一致,保证同一引用在各子句里等价):
* 1. 精确命中(行键与引用完全一致,含 `alias.col` 形态);
* 2. 剥离别名前缀(`t.n` → `n`);
* 3. 唯一后缀匹配(行键是 `t.n` 而引用写作 `n`);
* 4. 以上都不中 → 依 `strict` 抛错或返回 `UNRESOLVED`。
*
* 多个后缀命中视为**歧义**(JOIN 里两表同名列),`strict` 下抛错 ——
* 静默取第一个正是"结果取决于表顺序"这类难查问题的来源。
*/
export function resolveColumnValue(
row: Record<string, unknown>,
reference: string,
opts: ResolveOptions,
): unknown {
const text = reference.trim();
if (text in row) return row[text];
// 别名前缀(`t.n` → `n`):JOIN 行用 `alias.col` 作键,单表路径的键不带前缀
if (text.includes('.')) {
const bare = text.split('.').pop()!;
if (bare in row) return row[bare];
}
// 唯一后缀匹配:行键 `t.n` 而引用写作 `n`
let found: unknown;
let hits = 0;
for (const key of Object.keys(row)) {
if (key.endsWith(`.${text}`)) {
found = row[key];
hits += 1;
}
}
if (hits === 1) return found;
if (opts.strict) {
throw new DatabaseError(
hits > 1
? `Ambiguous column "${text}" in ${opts.context}: present in multiple tables`
: `Unknown column "${text}" in ${opts.context}`,
'COLUMN_NOT_FOUND',
{ column: text },
);
}
return UNRESOLVED;
}