/** * 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]> = [ ['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>( 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>( 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>( 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>( 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>( 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>( 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>( 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>( 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>( 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']); }); }); });