Files
MetonaSqlark/tests/v080-case-expression.test.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

322 lines
14 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.
/**
* 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']);
});
});
});