修复前 `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 已重建。
322 lines
14 KiB
TypeScript
322 lines
14 KiB
TypeScript
/**
|
||
* 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 → small(n=1 / NULL),id=2/3 → big(n=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']);
|
||
});
|
||
});
|
||
});
|