Files
MetonaSqlark/src/query/column-value.ts
T
thzxx 3983aae426 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 已重建。
2026-09-15 07:26:20 +08:00

90 lines
3.6 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 列引用取值 —— 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;
}