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;
}
+107 -135
View File
@@ -16,8 +16,12 @@ import { DatabaseError } from '../constants';
import { compileStatement } from './compiler';
import { createSchema, astColumnToColumnDef } from '../table/schema';
import { matchWhere, applyOrderBy, projectColumns, containsUnresolvedSubqueries } from './where-matcher';
import { parseWhereCondition } from '../sql/parser';
import { encodeValueKey } from './sql-compare';
import {
parseCaseExpression, evaluateCase,
assertCaseColumnsExist, type CaseExpression,
} from './expression';
import { resolveColumnValue } from './column-value';
// ---------------------------------------------------------------------------
// SELECT 执行形态(v0.8.0
@@ -65,85 +69,20 @@ import type { WhereCondition } from '../constants';
// 且编码不会与数据内容冲突。
// ---------------------------------------------------------------------------
// CASE WHEN 表达式v0.3.1
// CASE WHEN 表达式
// ---------------------------------------------------------------------------
//
// v0.8.0B-4):解析与求值已迁移到 `query/expression.ts`。
//
// 为什么必须搬走:原实现用**正则**在 SQL 文本上切分 WHEN/THEN/ELSE,不认字符串
// 字面量与嵌套结构,实测出静默错值:
// `CASE WHEN n>10 THEN CASE WHEN n>25 THEN 'huge' ELSE 'big' END ELSE 'small' END`
// → 返回字符串 "big' END ELSE 'small"(正则把嵌套 CASE 的 ELSE 当成自己的分支
// 边界,残片被原样返回给用户);条件解析失败时还 `cond = null` 静默跳过该
// 分支 → 整列变成 ELSE 值而不报错。
// 新实现复用 `sql/lexer` 的 token 流(带 position)做递归下降,天然支持嵌套与
// 字面量内的关键字,并把条件交给与 WHERE **完全相同**的解析器。
interface CaseWhenClause {
/** 条件(已解析为 WhereCondition,解析失败为 null 表示跳过) */
cond: WhereCondition | null;
/** THEN 值(字面量或列引用文本) */
value: string;
}
interface CaseExpression {
whens: CaseWhenClause[];
elseValue: string | null;
alias: string | null;
}
/**
* v0.8.0: 解析 `expr AS alias` 中的 `expr` 取值来源。
*
* 此前只处理"字符串常量"与"列引用"两种情况,数字/布尔/NULL 常量会走
* `row[source]` → undefined,于是 `SELECT 1 AS one FROM t` 返回 `[{}]`
* (键在、值为 undefined,JSON 序列化后整个键消失),
* 而这正是 EXISTS 子查询里最常见的写法(`SELECT 1 FROM ...`)。
*/
/**
* 列引用取值(唯一实现)。
*
* v0.8.0(A25):为什么必须有这一个函数。
*
* "从行里取一列"此前散落在 4 处、规则各不相同:
* - `projectColumns`where-matcher)精确匹配 + 唯一后缀匹配;
* - `resolveAliasSource` 只做 `row[text]`**没有**后缀回退;
* - 聚合参数直接 `r[argCol]`,既没有后缀回退,也没有"列不存在"的报错;
* - `matchWhere` 的 `resolveField` 又有自己的一套。
* 于是 `SELECT COUNT(t.n) FROM t` 返回 0:行键是 `n`,而聚合参数是 `t.n`,
* 直接取值得到 undefined,再被 `.filter(v => v != null)` 剔除 —— 计数为 0
* **不报错**。而同一个 `t.n` 写在 SELECT 列表里(`SELECT t.n`)却正常,
* 因为那条路径会先剥离别名前缀。
*
* 本函数统一三件事:别名前缀剥离 → 精确匹配 → 唯一后缀匹配;
* 都不命中时由调用方决定是抛错还是返回 undefined`onMissing`)。
*/
function resolveColumnValue(
row: Record<string, unknown>,
reference: string,
opts: { strict: boolean; context: string },
): 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 undefined;
}
/**
* 解析聚合函数表达式 —— **唯一**的聚合识别实现。
@@ -240,6 +179,23 @@ function bareReference(reference: string): string {
return text.split('.').pop()!;
}
/**
* 求值一个 GROUP BY 分组项(v0.8.0 / B-4)。
*
* 分组项有两种形态:
* - **列引用**(含 `表.列`)→ 走共享的 `resolveColumnValue`
* - **CASE 表达式** → 逐行求值(`GROUP BY CASE WHEN ... END`)。
*
* 之所以要这个包装而不是在调用点内联判断:分组键在两处用到
*(建组时逐行、输出分组行时取首行),两处必须用**完全相同**的求值规则,
* 否则会再次出现"键相同但输出值不同"的漂移。
*/
function resolveGroupKeyValue(row: Record<string, unknown>, item: string): unknown {
const caseExpr = parseCaseExpression(item);
if (caseExpr) return evaluateCase(caseExpr, row);
return resolveColumnValue(row, item, { strict: true, context: 'GROUP BY' });
}
function resolveAliasSource(source: string, row: Record<string, unknown>): unknown {
const text = source.trim();
// 字符串常量(含 SQL 标准 '' 转义还原)
@@ -286,58 +242,6 @@ function reduceNumeric(values: number[], op: 'SUM' | 'AVG' | 'MIN' | 'MAX'): num
return op === 'AVG' ? acc / values.length : acc;
}
/** 解析 "CASE WHEN c1 THEN v1 WHEN c2 THEN v2 ELSE v3 END [AS alias]" */
function parseCaseExpression(expr: string): CaseExpression | null {
const m = expr.match(/^\s*CASE\s+([\s\S]*?)\s+END\s*(?:AS\s+(\w+))?\s*$/i);
if (!m) return null;
const body = m[1];
const alias = m[2] ?? null;
const whens: CaseWhenClause[] = [];
const re = /WHEN\s+([\s\S]*?)\s+THEN\s+([\s\S]*?)(?=\s+WHEN\s+|\s+ELSE\s+|\s*$)/gi;
let match: RegExpExecArray | null;
while ((match = re.exec(body)) !== null) {
let cond: WhereCondition | null = null;
try {
cond = parseWhereCondition(match[1].trim());
} catch {
// 条件解析失败视为不匹配
}
whens.push({ cond, value: match[2].trim() });
}
let elseValue: string | null = null;
const elseMatch = body.match(/\sELSE\s+([\s\S]*)$/i);
if (elseMatch) elseValue = elseMatch[1].trim();
return { whens, elseValue, alias };
}
/** 解析 CASE 值:字面量(null/true/false/数字/字符串)优先,其次列引用 → 行值 */
function resolveCaseValue(text: string, row: Record<string, unknown>): unknown {
const v = text.trim();
if (v === 'null') return null;
if (v === 'true') return true;
if (v === 'false') return false;
const num = Number(v);
if (v !== '' && !isNaN(num)) return num;
const str = v.match(/^'(.*)'$/s) || v.match(/^"(.*)"$/s);
if (str) return str[1];
if (/^[a-zA-Z_][a-zA-Z0-9_.]*$/.test(v)) {
return row[v] ?? null; // 列引用(含 table.col
}
return v;
}
/** 对行求值 CASE WHEN 表达式 */
function evaluateCase(expr: CaseExpression, row: Record<string, unknown>): unknown {
for (const { cond, value } of expr.whens) {
if (cond && matchWhere(row, cond)) {
return resolveCaseValue(value, row);
}
}
return expr.elseValue !== null ? resolveCaseValue(expr.elseValue, row) : null;
}
// ---------------------------------------------------------------------------
// Executor
@@ -683,6 +587,14 @@ export class QueryExecutor {
const { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe } = shape;
let rows: Record<string, unknown>[];
// v0.8.0B-4):CASE 表达式里的列引用必须在**分组/聚合之前**校验。
//
// 时机很关键:分组会把行替换为"分组键 + 聚合值",此后源列已不存在,
// 任何基于行的校验都会误报未知列(实测:`GROUP BY CASE ... END` 在投影期
// 校验会报 Unknown column "n")。这里按**schema** 建可见列集合,
// 因此与行形状无关,天然正确。
await this.assertCaseColumnsExist(stmt, isJoinQuery);
// v0.8.0A37):WHERE 里的列引用也必须校验存在性。
//
// 投影侧的同类校验(assertProjectionColumnsExist)早已存在,但 WHERE 侧一直
@@ -1115,8 +1027,13 @@ export class QueryExecutor {
for (const row of rows) {
// v0.7.4: 类型安全键编码 —— 此前 String(row[col] ?? 'null') 使
// null 与字符串 'null' 合并为一组(GROUP BY 静默少组)
//
// v0.8.0B-4):分组项可以是**表达式**(CASE),此时按行求值取分组键 ——
// 此前一律当列名去查,于是 `GROUP BY CASE ... END` 报
// `COLUMN_NOT_FOUND Unknown column "CASE WHEN ..."`(实测),
// 而"按条件分组"是 SQL 里最常见的分析写法之一。
const key = groupColumns
.map((col) => encodeValueKey(resolveColumnValue(row, col, { strict: true, context: 'GROUP BY' })))
.map((col) => encodeValueKey(resolveGroupKeyValue(row, col)))
.join('\x1f');
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(row);
@@ -1134,7 +1051,16 @@ export class QueryExecutor {
// 分组列以基列名输出(`GROUP BY grp` 引用别名时输出基列 `g`,
// 与 SELECT 列表里 `g AS grp` 的投影可正确对应)
for (const col of groupColumns) {
aggregated[col] = resolveColumnValue(first, col, { strict: true, context: 'GROUP BY' });
// 分组项是 CASE 表达式时,组内该表达式的值恒定(正是分组依据),
// 取组内首行求值即可;否则按列名取值。
//
// 键名必须与"调用方查它时用的键"一致:
// - 别名形式(`CASE ... END AS band`,经 resolveGroupByColumns 解析为原文)
// → 用**别名** `band`,因为 projectGroupedRow 按别名索引;
// - 裸列 → 用列名。
const caseExpr = parseCaseExpression(col);
const key = caseExpr?.alias ?? col;
aggregated[key] = resolveGroupKeyValue(first, col);
}
// 先算聚合(含仅 HAVING 引用的),统一以 exprKey 与输出键写入
@@ -1222,7 +1148,7 @@ export class QueryExecutor {
output[agg.outputKey] = aggregated[agg.outputKey];
continue;
}
const caseExpr = /^\s*CASE\b/i.test(colExpr) ? parseCaseExpression(colExpr) : null;
const caseExpr = parseCaseExpression(colExpr);
if (caseExpr) {
const key = caseExpr.alias ?? colExpr;
output[key] = aggregated[key];
@@ -1249,6 +1175,13 @@ export class QueryExecutor {
private resolveGroupByColumns(stmt: SelectStatement): string[] {
const aliases = new Map<string, string>();
for (const col of stmt.columns) {
// v0.8.0B-4):CASE 的 `AS alias` 也要进别名表 ——
// `SELECT CASE ... END AS band ... GROUP BY band` 是最常见的条件分组写法。
const caseExpr = parseCaseExpression(col);
if (caseExpr?.alias) {
aliases.set(caseExpr.alias, col.trim());
continue;
}
const m = col.match(/^(.+?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
if (m) aliases.set(m[2], m[1].trim());
}
@@ -1291,7 +1224,7 @@ export class QueryExecutor {
*/
private computeAggregate(func: string, rows: Record<string, unknown>[], arg: string): number | null {
// v0.3.2: 聚合参数支持 CASE WHEN 表达式(如 SUM(CASE WHEN age > 18 THEN 1 ELSE 0 END)
const caseExpr = /^\s*CASE\b/i.test(arg) ? parseCaseExpression(arg) : null;
const caseExpr = parseCaseExpression(arg);
// v0.4.0: COUNT(DISTINCT col) —— distinct 由 parseAggregateExpression 剥离后传入
const argCol = arg.trim();
// v0.8.0A25):列引用统一走 resolveColumnValue(别名前缀剥离 + 后缀回退)。
@@ -1729,7 +1662,7 @@ export class QueryExecutor {
names.add(agg.outputKey);
continue;
}
const caseExpr = /^\s*CASE\b/i.test(col) ? parseCaseExpression(col) : null;
const caseExpr = parseCaseExpression(col);
if (caseExpr) {
names.add(caseExpr.alias ?? col);
continue;
@@ -1825,6 +1758,42 @@ export class QueryExecutor {
}
}
/**
* v0.8.0B-4):校验 SELECT / GROUP BY / HAVING / ORDER BY 里 CASE 表达式引用的列存在。
*
* 为什么单独一个方法:CASE 可以出现在四个子句里,而每个子句的校验时机不同
* WHERE 有 `assertWhereColumnsExist`,投影有 `assertProjectionColumnsExist`)。
* 统一在这里按 **schema** 收集可见列,与行形状解耦,避免"分组后校验不到源列"。
*/
private async assertCaseColumnsExist(stmt: SelectStatement, isJoinQuery: boolean): Promise<void> {
if (stmt.fromSubquery) return; // 派生表列来自子查询投影,需另行解析
if (!stmt.from) return;
const available = new Set<string>();
const addTable = async (table: string, alias: string): Promise<void> => {
const schema = await this.engine.getTableSchema(table);
if (!schema) return;
for (const col of Object.keys(schema.columns)) {
available.add(col);
available.add(`${alias}.${col}`);
}
};
await addTable(stmt.from, stmt.alias ?? stmt.from);
if (isJoinQuery) {
for (const join of stmt.joins ?? []) await addTable(join.table, join.alias ?? join.table);
}
const check = (text: string, context: string): void => {
const caseExpr = parseCaseExpression(text);
if (caseExpr) assertCaseColumnsExist(caseExpr, available, context);
};
for (const col of stmt.columns) check(col, `CASE expression "${col.trim()}"`);
for (const groupCol of stmt.groupBy ?? []) check(groupCol, `GROUP BY "${groupCol}"`);
for (const order of stmt.orderBy ?? []) check(order.column, `ORDER BY "${order.column}"`);
// HAVING 的 CASE 出现在键位(`HAVING CASE ... END = 1`
for (const key of Object.keys(stmt.having ?? {})) check(key, `HAVING "${key}"`);
}
/**
* v0.8.0A37):校验 WHERE 中出现的列名都存在于行源。
*
@@ -2032,6 +2001,9 @@ export class QueryExecutor {
for (const raw of columns) {
const col = raw.trim();
if (col === '*') continue;
// CASE 表达式在此跳过:它内部的列引用由 assertCaseColumnsExist 在
// **分组/聚合之前**(仍在源行形状上)单独校验 —— 此处 rows 可能已被
// 分组替换(只剩分组键与聚合值),拿不到源列,会误报未知列。
if (parseCaseExpression(col)) continue;
let reference = col;
+464
View File
@@ -0,0 +1,464 @@
/**
* metona-sqlark 表达式解析与求值 —— CASE WHEN 的结构化实现(v0.8.0 / B-4
* @module query/expression
*
* ============================================================================
* 为什么必须换掉原实现(PLAN-v0.7.5.md 根因 3:字符串化 AST 表达式列)
* ============================================================================
* 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE
*
* ```ts
* /WHEN\s+([\s\S]*?)\s+THEN\s+([\s\S]*?)(?=\s+WHEN\s+|\s+ELSE\s+|\s*$)/gi
* ```
*
* 它不认字符串字面量、不认嵌套结构,于是实测出三类错误结果:
*
* | 输入 | 实测(修复前) | 应有 |
* |---|---|---|
* | `CASE WHEN n>10 THEN CASE WHEN n>25 THEN 'huge' ELSE 'big' END ELSE 'small' END` | `"big' END ELSE 'small"` / `null` | `huge`/`big`/`small` |
* | `CASE WHEN s='WHEN' THEN 'hit' ELSE 'miss' END` | 依赖切分点,可能错 | `miss/hit/miss` |
*
* 更严重的是**静默错值**:条件解析失败(如引用不存在的列)时 `cond = null`
* 求值直接跳过该分支 —— 整列变成 ELSE 值,没有任何提示;而同一列名出现在
* WHERE 里会正常报错。同一语义两套行为。
*
* ============================================================================
* 本模块的做法
* ============================================================================
* 1. **复用 `sql/lexer` 的 token 流**(带 `position`),不另写一套词法规则 ——
* 字符串里的 `WHEN`/`ELSE`、转义引号、注释都由它正确处理,
* 因此"切分点"不再可能落在字面量内部。
* 2. **递归下降**解析 CASE(天然支持嵌套),并把每个片段按**源码位置切片**,
* 交给既有的 `parseWhereCondition` / 字面量解析器处理 ——
* 条件语义与 WHERE 完全同源,不再各写一份。
* 3. **解析失败即报错**`PARSE_ERROR` / `COLUMN_NOT_FOUND`),不静默降级为
* 某一个分支的值。
* 4. 解析结果**缓存**`parseCaseExpression` 是纯函数,可安全记忆化;
* 聚合与逐行投影会对同一表达式反复求值(N 行 × M 次),缓存把解析开销
* 从 O(行数) 降到 O(1)。
*
* 注意:本模块只负责 **CASE** 表达式。普通列引用/字面量/聚合由
* `query/executor` 的 `resolveColumnValue` 与 `parseAggregateExpression`
* 处理(B-4 的另一半已由 A25 统一)。
*/
import { tokenize } from '../sql/lexer';
import { TokenType } from '../sql/tokens';
import { parseWhereCondition } from '../sql/parser';
import type { WhereCondition } from '../constants';
import { DatabaseError } from '../constants';
import { matchWhere } from './where-matcher';
import { isSqlNull, sqlCompareOrder, UNRESOLVED, isUnresolved } from './sql-compare';
import { resolveColumnValue } from './column-value';
// ---------------------------------------------------------------------------
// 结构化 CASE 表达式
// ---------------------------------------------------------------------------
/** 单个 `WHEN <条件> THEN <值>` 分支 */
export interface CaseWhenBranch {
/** 条件的源码片段(已 trim) */
conditionText: string;
/** 值的源码片段(已 trim) */
resultText: string;
/**
* 条件解析结果。解析在**解析期**完成(失败即抛错),因此这里在成功构造的
* 表达式上恒为已解析对象 —— 不再有"cond = null 表示解析失败"的隐式约定。
*/
condition: WhereCondition;
}
/** 结构化 CASE 表达式 */
export interface CaseExpression {
/** 分支列表(按出现顺序) */
branches: CaseWhenBranch[];
/** `ELSE <值>` 的源码片段;未写 ELSE 时为 null(求值为 NULL */
elseText: string | null;
/** `... END AS alias` 的别名 */
alias: string | null;
/** 表达式原文(用于输出列名与错误信息) */
source: string;
}
// ---------------------------------------------------------------------------
// 解析
// ---------------------------------------------------------------------------
/** 解析缓存:同一段文本只解析一次(纯函数,可安全记忆化) */
const caseParseCache = new Map<string, CaseExpression>();
/** 缓存上限(防御性:避免长生命周期进程里无界增长) */
const CASE_CACHE_LIMIT = 512;
/**
* 解析 CASE WHEN 表达式文本。
*
* @param expr 形如 `CASE WHEN a > 1 THEN 'x' ELSE 'y' END AS band` 的片段
* @returns 结构化表达式;**不是** CASE 表达式时返回 null(调用方据此走其它分支)
* @throws DatabaseError 结构不完整(缺 THEN/END)或条件无法解析
*/
export function parseCaseExpression(expr: string): CaseExpression | null {
const text = expr.trim();
if (!/^\s*CASE\b/i.test(text)) return null;
const cached = caseParseCache.get(text);
if (cached) return cached;
const parsed = parseCaseExpressionUncached(text);
// 先清理再写入:条目数达到上限时整表清空,避免无界增长。
// 用 LRU 会引入额外状态;解析本身是纯函数且调用点集中在少数表达式上,
// "清空重建"足够且没有正确性风险。
if (caseParseCache.size >= CASE_CACHE_LIMIT) caseParseCache.clear();
caseParseCache.set(text, parsed);
return parsed;
}
/** 清空解析缓存(测试用;schema 变化不影响 CASE 结构,故生产无需清理) */
export function clearCaseParseCache(): void {
caseParseCache.clear();
}
/**
* 真正的解析实现。
*
* 用 `tokenize` 得到带位置的 token 流后按源码切片 —— 这样"条件/值片段"与
* 原始 SQL 逐字符一致(含引号与转义),可以安全地交给 `parseWhereCondition`。
*/
function parseCaseExpressionUncached(text: string): CaseExpression {
const tokens = tokenize(text);
let i = 0;
const fail = (message: string, position: number): never => {
throw new DatabaseError(`${message} (at offset ${position} in "${text}")`, 'PARSE_ERROR');
};
// CASE
if (tokens[i]?.type !== TokenType.CASE) fail('Expected CASE', tokens[i]?.position ?? 0);
i++;
const branches: CaseWhenBranch[] = [];
let elseText: string | null = null;
/**
* 按源码位置取片段。
*
* 边界必须用"**下一个 token 的起始位置**"作为右开区间:
* STRING token 的 `position` 指向**引号之内**lexer 的 readString 里
* `start = position + 1`),用 `position + value.length` 之类算术会把
* 结尾引号切掉(实测:`'small'` 被切成 `"small'"`CASE 全部报
* NOT_SUPPORTED —— 这正是本函数必须存在的理由)。
* 同理 NUMBER/IDENTIFIER 的 position 也由各自 reader 回推,语义不完全统一;
* 只有"下一个 token 的起点"是对所有 token 类型都成立的边界。
*/
const slice = (startIndex: number, endIndexExclusive: number): string => {
const startTok = tokens[startIndex];
if (!startTok) return '';
const endTok = tokens[endIndexExclusive];
const endPos = endTok ? endTok.position : text.length;
return text.slice(startTok.position, endPos).trim();
};
while (i < tokens.length) {
const tok = tokens[i];
if (tok.type === TokenType.WHEN) {
const condStart = i + 1;
// 找与之配对的 THEN:跳过嵌套的括号(CASE 内的子查询/括号表达式)
let depth = 0;
let thenIndex = -1;
for (let j = condStart; j < tokens.length; j++) {
const t = tokens[j];
if (t.type === TokenType.LPAREN) depth++;
else if (t.type === TokenType.RPAREN) depth--;
else if (depth === 0 && t.type === TokenType.THEN) { thenIndex = j; break; }
else if (depth === 0 && (t.type === TokenType.ELSE || t.type === TokenType.END)) break;
}
if (thenIndex < 0) fail('CASE WHEN without matching THEN', tok.position);
const valueStart = thenIndex + 1;
// 值的结束点:下一个同级 WHEN / ELSE / END
let depth2 = 0;
let valueEnd = tokens.length;
for (let j = valueStart; j < tokens.length; j++) {
const t = tokens[j];
if (t.type === TokenType.LPAREN) depth2++;
else if (t.type === TokenType.RPAREN) depth2--;
else if (depth2 === 0 && (t.type === TokenType.WHEN || t.type === TokenType.ELSE || t.type === TokenType.END)) {
valueEnd = j;
break;
} else if (depth2 === 0 && t.type === TokenType.CASE) {
// 嵌套 CASE 作为一个整体:跳到与它配对的 END
let nestedDepth = 1;
for (let k = j + 1; k < tokens.length; k++) {
if (tokens[k].type === TokenType.CASE) nestedDepth++;
else if (tokens[k].type === TokenType.END) {
nestedDepth--;
if (nestedDepth === 0) { j = k; break; }
}
}
}
}
const conditionText = slice(condStart, thenIndex);
const resultText = slice(valueStart, valueEnd);
if (!conditionText) fail('CASE WHEN has an empty condition', tok.position);
if (!resultText) fail('CASE THEN has an empty result', tokens[valueStart]?.position ?? tok.position);
// 条件在**解析期**交给与 WHERE 完全相同的解析器 —— 语义同源,
// 且"引用不存在的列"之类问题按 WHERE 的口径处理(不再静默跳过分支)。
let condition: WhereCondition;
try {
condition = parseWhereCondition(conditionText);
} catch (error) {
throw new DatabaseError(
`Invalid CASE WHEN condition "${conditionText}": ${(error as Error).message}`,
'PARSE_ERROR',
{ condition: conditionText },
);
}
branches.push({ conditionText, resultText, condition });
i = valueEnd;
continue;
}
if (tok.type === TokenType.ELSE) {
const elseStart = i + 1;
let depth = 0;
let elseEnd = tokens.length;
for (let j = elseStart; j < tokens.length; j++) {
const t = tokens[j];
if (t.type === TokenType.LPAREN) depth++;
else if (t.type === TokenType.RPAREN) depth--;
else if (depth === 0 && t.type === TokenType.END) { elseEnd = j; break; }
else if (depth === 0 && t.type === TokenType.CASE) {
let nestedDepth = 1;
for (let k = j + 1; k < tokens.length; k++) {
if (tokens[k].type === TokenType.CASE) nestedDepth++;
else if (tokens[k].type === TokenType.END) {
nestedDepth--;
if (nestedDepth === 0) { j = k; break; }
}
}
}
}
elseText = slice(elseStart, elseEnd);
if (!elseText) fail('CASE ELSE has an empty result', tok.position);
i = elseEnd;
continue;
}
if (tok.type === TokenType.END) {
i++;
break;
}
fail(`Unexpected token "${tok.value}" in CASE`, tok.position);
}
if (branches.length === 0) {
throw new DatabaseError(`CASE expression has no WHEN branch: "${text}"`, 'PARSE_ERROR');
}
// 可选别名:END AS alias / END alias
let alias: string | null = null;
const rest = tokens.slice(i).filter((t) => t.type !== TokenType.EOF && t.type !== TokenType.SEMICOLON);
if (rest.length > 0) {
const first = rest[0];
if (first.type === TokenType.AS) {
const aliasTok = rest[1];
if (aliasTok && (aliasTok.type === TokenType.IDENTIFIER || aliasTok.type === TokenType.QUOTED_IDENTIFIER)) {
alias = aliasTok.value;
}
} else if (first.type === TokenType.IDENTIFIER || first.type === TokenType.QUOTED_IDENTIFIER) {
alias = first.value;
}
}
return { branches, elseText, alias, source: text };
}
// ---------------------------------------------------------------------------
// 求值
// ---------------------------------------------------------------------------
/**
* 对一行求值 CASE 表达式。
*
* 求值顺序即声明顺序:第一个条件为 TRUE 的分支胜出;
* 无分支命中时取 ELSE(未写 ELSE 则为 NULL)。
*
* 条件用 `matchWhere`(三值逻辑)判定:UNKNOWN **不**算命中
* (与 WHERE 只保留 TRUE 的语义一致)。
*/
export function evaluateCase(
expr: CaseExpression,
row: Record<string, unknown>,
): unknown {
for (const branch of expr.branches) {
if (matchWhere(row, branch.condition, { $col: true })) {
return evaluateExpressionValue(branch.resultText, row);
}
}
return expr.elseText !== null ? evaluateExpressionValue(expr.elseText, row) : null;
}
/**
* 校验 CASE 条件里引用的列在行源中存在(v0.8.0 / B-4)。
*
* 为什么必须有:条件用 `matchWhere` 求值时,**引用不存在的列**只会得到
* UNKNOWN(三值逻辑的正确行为 —— 引擎层拿不到 schema),于是该分支永不命中,
* 整列静默变成 ELSE 值。实测修复前:
* `CASE WHEN nope > 1 THEN 'x' ELSE 'y' END` → 每行都是 'y',无任何报错;
* 而同一个 `nope` 写在 WHERE 里会正常抛 COLUMN_NOT_FOUND。
* 同一语义两套行为,且失败方向是"静默错值"。
*
* 与 `assertWhereColumnsExist` 的关系:那条路径校验的是 SQL 的 WHERE 子句,
* 它拿得到表名与别名;CASE 出现在 SELECT/GROUP BY/HAVING 里,调用点更分散,
* 因此这里做**独立的、可复用的**校验,由调用方在有 schema 时调用。
*
* @param available 该作用域内可见的列名集合(含 `alias.col` 形态;JOIN 时是两表并集)
*/
export function assertCaseColumnsExist(
expr: CaseExpression,
available: ReadonlySet<string>,
context: string,
): void {
const missing: string[] = [];
const checkRef = (ref: string): void => {
const text = ref.trim();
if (!text) return;
if (available.has(text)) return;
const bare = text.includes('.') ? text.split('.').pop()! : text;
if (available.has(bare)) return;
missing.push(text);
};
// 条件里的列引用:键位(`n > 1`)与 `$col` 值位(`a = b`
const walkCondition = (cond: WhereCondition): void => {
for (const [key, value] of Object.entries(cond)) {
if (key === '$and' || key === '$or') {
for (const sub of (Array.isArray(value) ? value : [value]) as WhereCondition[]) walkCondition(sub);
continue;
}
if (key === '$not') { walkCondition(value as WhereCondition); continue; }
if (key === '$exists' || /^\s*CASE\b/i.test(key)) continue;
checkRef(key);
if (value !== null && typeof value === 'object') {
for (const operand of Object.values(value as Record<string, unknown>)) {
if (operand !== null && typeof operand === 'object' && !Array.isArray(operand) && '$col' in (operand as object)) {
checkRef(String((operand as Record<string, unknown>).$col));
}
}
}
}
};
for (const branch of expr.branches) walkCondition(branch.condition);
// 结果片段里的列引用(嵌套 CASE 递归)
const checkResult = (text: string): void => {
const trimmed = text.trim();
if (/^'/.test(trimmed) || /^-?\d/.test(trimmed) || /^(NULL|TRUE|FALSE)$/i.test(trimmed)) return;
const nested = parseCaseExpression(trimmed);
if (nested) {
for (const b of nested.branches) walkCondition(b.condition);
for (const b of nested.branches) checkResult(b.resultText);
if (nested.elseText) checkResult(nested.elseText);
return;
}
if (/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(trimmed)) checkRef(trimmed);
};
for (const branch of expr.branches) checkResult(branch.resultText);
if (expr.elseText) checkResult(expr.elseText);
if (missing.length > 0) {
throw new DatabaseError(
`Unknown column${missing.length > 1 ? 's' : ''} ${missing.map((c) => `"${c}"`).join(', ')}`
+ ` in ${context}`,
'COLUMN_NOT_FOUND',
{ columns: missing },
);
}
}
/**
* 求值一个"值表达式"片段(THEN/ELSE 的操作数)。
*
* 支持的形态(覆盖既有全部用例,不引入静默降级):
* - 字符串字面量(含 `''` 转义)、数字、TRUE/FALSE/NULL
* - 列引用(裸列名或 `表.列`);
* - **嵌套 CASE**(递归求值);
* - 其它无法识别的文本 → 抛 `NOT_SUPPORTED`,而不是"当字符串返回"
* (原实现把无法识别的文本原样返回,于是嵌套 CASE 的残片
* `"big' END ELSE 'small"` 变成了用户可见的返回值)。
*/
export function evaluateExpressionValue(
text: string,
row: Record<string, unknown>,
): unknown {
const trimmed = text.trim();
if (trimmed === '') {
throw new DatabaseError('Empty expression value', 'PARSE_ERROR');
}
// 字符串字面量(SQL 标准 '' 转义)
const strLit = trimmed.match(/^'(.*)'$/s);
if (strLit) return strLit[1].replace(/''/g, "'");
// NULL / 布尔
if (/^NULL$/i.test(trimmed)) return null;
if (/^TRUE$/i.test(trimmed)) return true;
if (/^FALSE$/i.test(trimmed)) return false;
// 数字常量(含负号与小数)
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
// 嵌套 CASE
const nested = parseCaseExpression(trimmed);
if (nested) return evaluateCase(nested, row);
// 列引用(含 `表.列`)—— 未知列抛 COLUMN_NOT_FOUND(与投影路径同口径)
if (/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(trimmed)) {
const value = resolveColumnValue(row, trimmed, { strict: false, context: 'CASE result' });
if (isUnresolved(value)) {
throw new DatabaseError(`Unknown column "${trimmed}" in expression`, 'COLUMN_NOT_FOUND', {
column: trimmed,
});
}
return value;
}
throw new DatabaseError(
`Unsupported expression in CASE result: "${trimmed}"`,
'NOT_SUPPORTED',
{ expression: trimmed },
);
}
/** 判断文本是否为 CASE 表达式(供 executor 分流,避免到处写正则) */
export function isCaseExpression(text: string): boolean {
return /^\s*CASE\b/i.test(text);
}
/**
* 简单 CASE 形式(`CASE <表达式> WHEN <值> THEN ...`)的检测。
*
* 当前实现只支持**搜索式 CASE**`CASE WHEN <条件> THEN ...`)。简单 CASE
* 的 `CASE x WHEN 1 THEN` 语法在此前实现里也会被正则误当作条件解析。
* 这里显式识别并抛 NOT_SUPPORTED,避免"看起来支持、结果全错"。
*/
export function assertNoSimpleCaseForm(expr: CaseExpression, tokensText: string): void {
if (/^\s*CASE\s+(?!WHEN\b)/i.test(tokensText)) {
throw new DatabaseError(
'Simple CASE form (CASE <expr> WHEN <value>) is not supported; use searched CASE'
+ ' (CASE WHEN <condition> THEN <result> ... END) instead',
'NOT_SUPPORTED',
);
}
void expr;
}
/** 供 executor 判断比较顺序的工具(保留三值语义入口,避免各处重复实现) */
export function compareForCase(a: unknown, b: unknown): number | null {
if (isSqlNull(a) || isSqlNull(b) || isUnresolved(a) || isUnresolved(b)) return null;
return sqlCompareOrder(a, b);
}
/** 供调用方复用的哨兵(避免重复 import sql-compare */
export { UNRESOLVED };