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
+321
View File
@@ -0,0 +1,321 @@
/**
* v0.8.0 回归套件 —— B-4 结构化 CASE 表达式
* ============================================================================
* 修复前 `parseCaseExpression` 用**正则**在 SQL 文本上切分 WHEN/THEN/ELSE
*
* ```ts
* /WHEN\s+([\s\S]*?)\s+THEN\s+([\s\S]*?)(?=\s+WHEN\s+|\s+ELSE\s+|\s*$)/gi
* ```
*
* 它不认字符串字面量、不认嵌套结构,于是产生三类错误结果(全部实测确认):
*
* | 输入 | 修复前实测 | 应有 |
* |---|---|---|
* | 嵌套 CASE`THEN CASE ... END ELSE ...` | `"big' END ELSE 'small"` / `null` | huge/big/small |
* | 条件引用不存在的列 | 每行静默变成 ELSE 值,**无报错** | COLUMN_NOT_FOUND |
* | `GROUP BY CASE ... END` | `COLUMN_NOT_FOUND Unknown column "CASE WHEN ..."` | 正常分组 |
*
* 第三项尤其反直觉:错误信息说"未知列 CASE WHEN ...",因为分组把 CASE 原文
* 当成了列名 —— 而"按条件分组"是 SQL 里最常见的分析写法之一。
*
* 新实现(`src/query/expression.ts`)复用 `sql/lexer` 的 token 流做递归下降,
* 并把条件交给与 WHERE **完全相同**的 `parseWhereCondition`。
*
* 顺带修掉的词法层缺陷:`Token.position` 的语义**按 token 类型不一致** ——
* `readString` 用 `position + 1`(指向引号之内),`readIdentifier`/`readNumber`
* 用 `position - len`(指向首字符)。任何"按 position 切片"的调用方都会对字符串
* 多切/少切一个字符(实测:`'big'` 被切成 `"big'"`)。现统一为"token 首字符下标"。
*/
import { describe, it, expect, beforeEach } from '@jest/globals';
import { MetonaSqlark } from '../src/core';
import { parseCaseExpression, evaluateCase, assertCaseColumnsExist } from '../src/query/expression';
import { tokenize } from '../src/sql/lexer';
import { TokenType } from '../src/sql/tokens';
import { rows as rowsOf } from './helpers/assertions';
import type { DatabaseConfig } from '../src/constants';
const ENGINES: Array<[string, DatabaseConfig['mode'], Partial<DatabaseConfig>]> = [
['memory', 'memory', {}],
['disk', 'disk', {}],
['hybrid', 'hybrid', {}],
['aria', 'aria', { diskEngine: 'memory' }],
];
// ---------------------------------------------------------------------------
// 词法层:position 必须统一为"token 首字符下标"
// ---------------------------------------------------------------------------
describe('[v0.8.0] B-4 词法层:Token.position 语义统一', () => {
it('所有 token 的 position 都指向自己的第一个字符', () => {
const sql = `SELECT 'abc', "col", ident, 123 FROM t`;
for (const tok of tokenize(sql)) {
if (tok.type === TokenType.EOF) continue;
// 从 position 开始重新词法化,必须得到同一个 token —— 这是 position
// "指向首字符"的可判定判据(字符串/分隔标识符此前会差 1)。
const relexed = tokenize(sql.slice(tok.position));
expect(relexed[0].value).toBe(tok.value);
expect(relexed[0].type).toBe(tok.type);
}
});
it('字符串字面量含引号时切片完整(回归护栏)', () => {
const sql = `THEN 'big' ELSE`;
const tokens = tokenize(sql).filter((t) => t.type === TokenType.STRING);
expect(tokens).toHaveLength(1);
const start = tokens[0].position;
// 'big' 共 5 个字符(含两个引号)
expect(sql.slice(start, start + 5)).toBe("'big'");
});
});
// ---------------------------------------------------------------------------
// 解析层:结构化结果
// ---------------------------------------------------------------------------
describe('[v0.8.0] B-4 CASE 解析', () => {
it('基本结构:分支顺序、ELSE、别名', () => {
const expr = parseCaseExpression("CASE WHEN n > 10 THEN 'big' ELSE 'small' END AS band")!;
expect(expr.alias).toBe('band');
expect(expr.elseText).toBe("'small'");
expect(expr.branches).toHaveLength(1);
expect(expr.branches[0].conditionText).toBe('n > 10');
expect(expr.branches[0].resultText).toBe("'big'");
});
it('多分支 + 无 ELSE', () => {
const expr = parseCaseExpression("CASE WHEN a = 1 THEN 'x' WHEN a = 2 THEN 'y' END")!;
expect(expr.branches).toHaveLength(2);
expect(expr.elseText).toBeNull();
});
it('嵌套 CASE 被完整切分(修复前会被切成残片)', () => {
const expr = parseCaseExpression(
"CASE WHEN n > 10 THEN CASE WHEN n > 25 THEN 'huge' ELSE 'big' END ELSE 'small' END",
)!;
expect(expr.branches).toHaveLength(1);
expect(expr.branches[0].resultText)
.toBe("CASE WHEN n > 25 THEN 'huge' ELSE 'big' END");
expect(expr.elseText).toBe("'small'");
});
it('字符串字面量内的 WHEN/ELSE/THEN 不参与切分', () => {
for (const word of ['WHEN', 'ELSE', 'THEN', 'END']) {
const expr = parseCaseExpression(`CASE WHEN s = '${word}' THEN 'hit' ELSE 'miss' END`)!;
expect(expr.branches).toHaveLength(1);
expect(expr.branches[0].conditionText).toBe(`s = '${word}'`);
expect(expr.branches[0].resultText).toBe("'hit'");
expect(expr.elseText).toBe("'miss'");
}
});
it('条件里的括号不影响切分', () => {
const expr = parseCaseExpression("CASE WHEN (a > 1 AND b < 2) THEN 'x' ELSE 'y' END")!;
expect(expr.branches[0].conditionText).toBe('(a > 1 AND b < 2)');
});
it('非 CASE 文本返回 null(供调用方分流)', () => {
expect(parseCaseExpression('n > 10')).toBeNull();
expect(parseCaseExpression('COUNT(*)')).toBeNull();
});
it('结构不完整显式报错', () => {
expect(() => parseCaseExpression('CASE WHEN a > 1 END')).toThrow(/THEN/);
expect(() => parseCaseExpression('CASE END')).toThrow(/no WHEN branch/);
});
it('条件无法解析时报 PARSE_ERROR(不静默降级)', () => {
expect(() => parseCaseExpression('CASE WHEN THEN 1 ELSE 2 END')).toThrow();
});
it('解析结果被缓存(同文本多次解析返回同一对象)', () => {
const text = "CASE WHEN z > 1 THEN 'a' ELSE 'b' END";
expect(parseCaseExpression(text)).toBe(parseCaseExpression(text));
});
});
// ---------------------------------------------------------------------------
// 求值层:与行数据结合
// ---------------------------------------------------------------------------
describe('[v0.8.0] B-4 CASE 求值', () => {
it('第一个 TRUE 分支胜出;UNKNOWN 不算命中', () => {
const expr = parseCaseExpression("CASE WHEN a > 1 THEN 'big' WHEN a > 0 THEN 'small' ELSE 'none' END")!;
expect(evaluateCase(expr, { a: 5 })).toBe('big');
expect(evaluateCase(expr, { a: 2 })).toBe('big');
expect(evaluateCase(expr, { a: 0 })).toBe('none');
// NULL 参与比较 → UNKNOWN → 落到 ELSE(与 WHERE 只保留 TRUE 一致)
expect(evaluateCase(expr, { a: null })).toBe('none');
});
it('THEN/ELSE 支持字面量、列引用与 NULL', () => {
expect(evaluateCase(parseCaseExpression('CASE WHEN a > 0 THEN b ELSE NULL END')!, { a: 1, b: 'col-value' }))
.toBe('col-value');
expect(evaluateCase(parseCaseExpression('CASE WHEN a > 0 THEN 42 ELSE NULL END')!, { a: 1 })).toBe(42);
expect(evaluateCase(parseCaseExpression('CASE WHEN a > 0 THEN TRUE ELSE FALSE END')!, { a: 1 })).toBe(true);
expect(evaluateCase(parseCaseExpression("CASE WHEN a > 0 THEN 'it''s' ELSE 'no' END")!, { a: 1 })).toBe("it's");
});
it('嵌套 CASE 逐层求值', () => {
const expr = parseCaseExpression(
"CASE WHEN n > 10 THEN CASE WHEN n > 25 THEN 'huge' ELSE 'big' END ELSE 'small' END",
)!;
expect(evaluateCase(expr, { n: 30 })).toBe('huge');
expect(evaluateCase(expr, { n: 20 })).toBe('big');
expect(evaluateCase(expr, { n: 1 })).toBe('small');
});
it('无法识别的结果表达式抛 NOT_SUPPORTED(不把原文当字符串返回)', () => {
// 修复前:正则残片 `"big' END ELSE 'small"` 会被原样当成字符串返回给用户
const expr = parseCaseExpression('CASE WHEN a > 0 THEN a + 1 ELSE 0 END')!;
expect(() => evaluateCase(expr, { a: 1 })).toThrow(/Unsupported expression/);
});
it('结果引用不存在的列抛 COLUMN_NOT_FOUND', () => {
const expr = parseCaseExpression('CASE WHEN a > 0 THEN nope ELSE 0 END')!;
expect(() => evaluateCase(expr, { a: 1 })).toThrow(/Unknown column "nope"/);
});
});
// ---------------------------------------------------------------------------
// 列存在性校验
// ---------------------------------------------------------------------------
describe('[v0.8.0] B-4 CASE 列存在性校验', () => {
const available = new Set(['n', 's', 't.n', 't.s']);
it('条件与结果里的列都校验', () => {
expect(() => assertCaseColumnsExist(
parseCaseExpression('CASE WHEN nope > 1 THEN s ELSE s END')!, available, 'CASE',
)).toThrow(/Unknown column "nope"/);
expect(() => assertCaseColumnsExist(
parseCaseExpression('CASE WHEN n > 1 THEN nope ELSE s END')!, available, 'CASE',
)).toThrow(/Unknown column "nope"/);
});
it('合格引用不报错(含表别名与嵌套)', () => {
expect(() => assertCaseColumnsExist(
parseCaseExpression('CASE WHEN t.n > 1 THEN t.s ELSE n END')!, available, 'CASE',
)).not.toThrow();
expect(() => assertCaseColumnsExist(
parseCaseExpression("CASE WHEN n > 1 THEN CASE WHEN s = 'x' THEN 1 ELSE 0 END ELSE 2 END")!,
available, 'CASE',
)).not.toThrow();
});
});
// ---------------------------------------------------------------------------
// 端到端:四个引擎
// ---------------------------------------------------------------------------
describe('[v0.8.0] B-4 CASE 端到端(四引擎一致)', () => {
describe.each(ENGINES)('%s 引擎', (label, mode, extra) => {
let db: MetonaSqlark;
let seq = 0;
beforeEach(async () => {
seq += 1;
db = await MetonaSqlark.create({
name: `b4-${label}-${seq}-${Math.random().toString(36).slice(2)}`,
mode,
...extra,
});
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
n: { type: 'number' },
s: { type: 'string' },
});
await db.query("INSERT INTO t VALUES ('1',1,'a'),('2',20,'WHEN'),('3',30,'ELSE'),('4',NULL,'THEN')");
});
afterEach(async () => {
await db.close();
});
it('搜索式 CASE:分支与 ELSE 正确', async () => {
const rows = rowsOf<Record<string, unknown>>(
await db.query("SELECT id, CASE WHEN n > 10 THEN 'big' ELSE 'small' END AS band FROM t ORDER BY id"),
);
expect(rows).toEqual([
{ id: '1', band: 'small' },
{ id: '2', band: 'big' },
{ id: '3', band: 'big' },
{ id: '4', band: 'small' }, // n 为 NULL → UNKNOWN → ELSE
]);
});
it('嵌套 CASE(修复前返回字符串残片)', async () => {
const rows = rowsOf<Record<string, unknown>>(
await db.query(
"SELECT id, CASE WHEN n > 10 THEN CASE WHEN n > 25 THEN 'huge' ELSE 'big' END ELSE 'small' END AS r "
+ 'FROM t ORDER BY id',
),
);
expect(rows.map((r) => r.r)).toEqual(['small', 'big', 'huge', 'small']);
});
it('字符串字面量含关键字(修复前可能错切)', async () => {
const rows = rowsOf<Record<string, unknown>>(
await db.query("SELECT id, CASE WHEN s = 'WHEN' THEN 'hit' ELSE 'miss' END AS r FROM t ORDER BY id"),
);
expect(rows.map((r) => r.r)).toEqual(['miss', 'hit', 'miss', 'miss']);
});
it('无 ELSE 时未命中为 NULL', async () => {
const rows = rowsOf<Record<string, unknown>>(
await db.query("SELECT id, CASE WHEN n > 25 THEN 'only' END AS r FROM t ORDER BY id"),
);
expect(rows.map((r) => r.r)).toEqual([null, null, 'only', null]);
});
it('聚合 CASE(条件计数)', async () => {
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT SUM(CASE WHEN n > 10 THEN 1 ELSE 0 END) AS c FROM t'),
);
expect(rows).toEqual([{ c: 2 }]);
});
it('GROUP BY CASE 表达式(修复前抛"未知列 CASE WHEN ..."', async () => {
const rows = rowsOf<Record<string, unknown>>(
await db.query(
"SELECT CASE WHEN n > 10 THEN 'big' ELSE 'small' END AS band, COUNT(*) AS c "
+ 'FROM t GROUP BY band ORDER BY band',
),
);
expect(rows).toEqual([{ band: 'big', c: 2 }, { band: 'small', c: 2 }]);
});
it('WHERE 里的 CASE 条件', async () => {
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT id FROM t WHERE CASE WHEN n > 10 THEN 1 ELSE 0 END = 1 ORDER BY id'),
);
expect(rows.map((r) => r.id)).toEqual(['2', '3']);
});
it('未知列:条件里报 COLUMN_NOT_FOUND(修复前静默全为 ELSE 值)', async () => {
await expect(
db.query("SELECT CASE WHEN nope > 1 THEN 'x' ELSE 'y' END AS r FROM t"),
).rejects.toMatchObject({ code: 'COLUMN_NOT_FOUND' });
await expect(
db.query("SELECT CASE WHEN n > 1 THEN nope ELSE 'y' END AS r FROM t"),
).rejects.toMatchObject({ code: 'COLUMN_NOT_FOUND' });
});
it('CASE 别名列可被 ORDER BY 引用', async () => {
// 数据:id=1/4 → smalln=1 / NULL),id=2/3 → bign=20/30
// `band DESC` → 'small' > 'big'(字典序),故 small 组在前,组内按 id 升序
const rows = rowsOf<Record<string, unknown>>(
await db.query(
"SELECT id, CASE WHEN n > 10 THEN 'big' ELSE 'small' END AS band FROM t ORDER BY band DESC, id",
),
);
expect(rows.map((r) => r.id)).toEqual(['1', '4', '2', '3']);
// 升序则反过来
const asc = rowsOf<Record<string, unknown>>(
await db.query(
"SELECT id, CASE WHEN n > 10 THEN 'big' ELSE 'small' END AS band FROM t ORDER BY band, id",
),
);
expect(asc.map((r) => r.id)).toEqual(['2', '3', '1', '4']);
});
});
});