修复前 `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 已重建。
90 lines
3.6 KiB
TypeScript
90 lines
3.6 KiB
TypeScript
/**
|
||
* 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)` 返回 0(A25)、`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;
|
||
}
|