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
+589 -118
View File
@@ -11315,58 +11315,69 @@ class Lexer {
/** 读取下一个 Token */
nextToken() {
this.skipWhitespaceAndComments();
/**
* v0.8.0token **起始位置**在读到任何字符之前记录
*
* 为什么必须在这里记此前的 position 由各 reader 自行回推语义** token
* 类型不一致** `readString` `start = this.position + 1`指向引号**之内**
* `readIdentifier`/`readNumber` `this.position - len`指向首字符
* 于是 `'big'` position 23 而实际从 24 开始任何"按 position 切片"
* 调用方都会多切一个字符实测CASE THEN 值被切成 `"big'"`
* 这里统一为"token 首字符在源码中的下标"所有类型一致
*/
const tokenStart = this.position;
let tok;
switch (this.ch) {
case ',':
tok = this.makeToken(TokenType.COMMA, ',');
tok = this.makeToken(TokenType.COMMA, ',', tokenStart);
break;
case '(':
tok = this.makeToken(TokenType.LPAREN, '(');
tok = this.makeToken(TokenType.LPAREN, '(', tokenStart);
break;
case ')':
tok = this.makeToken(TokenType.RPAREN, ')');
tok = this.makeToken(TokenType.RPAREN, ')', tokenStart);
break;
case ';':
tok = this.makeToken(TokenType.SEMICOLON, ';');
break;
case '*':
tok = this.makeToken(TokenType.STAR, '*');
tok = this.makeToken(TokenType.STAR, '*', tokenStart);
break;
case '.':
tok = this.makeToken(TokenType.DOT, '.');
tok = this.makeToken(TokenType.DOT, '.', tokenStart);
break;
case '=':
tok = this.makeToken(TokenType.EQ, '=');
tok = this.makeToken(TokenType.EQ, '=', tokenStart);
break;
case '!':
if (this.peekChar() === '=') {
this.readChar();
tok = this.makeToken(TokenType.NEQ, '!=');
tok = this.makeToken(TokenType.NEQ, '!=', tokenStart);
}
else {
tok = this.makeToken(TokenType.ILLEGAL, '!');
tok = this.makeToken(TokenType.ILLEGAL, '!', tokenStart);
}
break;
case '>':
if (this.peekChar() === '=') {
this.readChar();
tok = this.makeToken(TokenType.GTE, '>=');
tok = this.makeToken(TokenType.GTE, '>=', tokenStart);
}
else {
tok = this.makeToken(TokenType.GT, '>');
tok = this.makeToken(TokenType.GT, '>', tokenStart);
}
break;
case '<':
if (this.peekChar() === '=') {
this.readChar();
tok = this.makeToken(TokenType.LTE, '<=');
tok = this.makeToken(TokenType.LTE, '<=', tokenStart);
}
else if (this.peekChar() === '>') {
this.readChar();
tok = this.makeToken(TokenType.NEQ, '<>');
tok = this.makeToken(TokenType.NEQ, '<>', tokenStart);
}
else {
tok = this.makeToken(TokenType.LT, '<');
tok = this.makeToken(TokenType.LT, '<', tokenStart);
}
break;
case "'":
@@ -11383,24 +11394,17 @@ class Lexer {
if (this.isLetter(this.ch)) {
const ident = this.readIdentifier();
const keyword = KEYWORDS[ident.toUpperCase()];
tok = {
type: keyword ?? TokenType.IDENTIFIER,
value: ident,
position: this.position - ident.length,
};
// v0.8.0: 用 tokenStart(首字符下标),不再用 position - len 回推
tok = { type: keyword ?? TokenType.IDENTIFIER, value: ident, position: tokenStart };
return tok; // 已读取完毕,不需要再 readChar
}
else if (this.isDigit(this.ch) || (this.ch === '-' && this.isDigit(this.peekChar()))) {
const num = this.readNumber();
tok = {
type: TokenType.NUMBER,
value: num,
position: this.position - num.length,
};
tok = { type: TokenType.NUMBER, value: num, position: tokenStart };
return tok;
}
else {
tok = this.makeToken(TokenType.ILLEGAL, this.ch);
tok = this.makeToken(TokenType.ILLEGAL, this.ch, tokenStart);
}
break;
}
@@ -11518,7 +11522,11 @@ class Lexer {
* 完全一致且任意以反斜杠结尾的参数值都能正确绑定
*/
readString() {
const start = this.position + 1; // 跳过一个引号
// v0.8.0: start = **开引号**的位置(此前 +1 指向引号之内,导致所有按
// position 切片的调用方都多切一个字符)。未闭合错误消息里用的 start 仍取
// 引号之后的位置,便于用户定位到内容起点。
const start = this.position;
const contentStart = this.position + 1;
this.readChar(); // 跳过开始引号
let value = '';
while (this.ch !== '') {
@@ -11538,7 +11546,7 @@ class Lexer {
// v0.7.2: 未闭合字符串字面量显式报错(此前静默返回残缺 STRING token
// 上层可解析出错误结果,如 `SELECT 'abc` 被当作合法常量列)
if (this.ch === '') {
throw new DatabaseError(`Unterminated string literal at position ${start}`, 'PARSE_ERROR');
throw new DatabaseError(`Unterminated string literal at position ${contentStart}`, 'PARSE_ERROR');
}
return {
type: TokenType.STRING,
@@ -11553,8 +11561,8 @@ class Lexer {
* 未闭合同样显式报错与字符串字面量保持一致
*/
readQuotedIdentifier() {
// v0.8.0: start = 开引号位置(position 语义统一为 token 首字符)
const start = this.position;
const contentStart = this.position + 1;
this.readChar(); // 跳过开始引号
let value = '';
while (this.ch !== '') {
@@ -11579,7 +11587,9 @@ class Lexer {
return {
type: TokenType.QUOTED_IDENTIFIER,
value,
position: contentStart,
// v0.8.0: 与 STRING 一致 —— position = **开引号**的位置(按 position 切片
// 才能取到完整的分隔标识符文本)
position: start,
};
}
isLetter(ch) {
@@ -11588,8 +11598,8 @@ class Lexer {
isDigit(ch) {
return /[0-9]/.test(ch);
}
makeToken(type, value) {
return { type, value, position: this.position };
makeToken(type, value, start = this.position) {
return { type, value, position: start };
}
}
// ---------------------------------------------------------------------------
@@ -12961,43 +12971,46 @@ function parseWhereCondition(sql) {
}
/**
* metona-sqlark Query Executor AST 执行器
* @module query/executor
* metona-sqlark 列引用取值 WHERE / 投影 / 聚合 / 表达式**共用**的唯一实现
* @module query/column-value
*
* JOIN / GROUP BY / DISTINCT 逻辑在此层处理
* ============================================================================
* 为什么必须只有一个实现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 里的未解析引用""表达式里的未解析引用"需要不同的上层处理
*/
/**
* v0.8.0: 解析 `expr AS alias` 中的 `expr` 取值来源
* 从行里取一个列引用
*
* 此前只处理"字符串常量""列引用"两种情况数字/布尔/NULL 常量会走
* `row[source]` undefined于是 `SELECT 1 AS one FROM t` 返回 `[{}]`
* 键在值为 undefinedJSON 序列化后整个键消失
* 而这正是 EXISTS 子查询里最常见的写法`SELECT 1 FROM ...`
*/
/**
* 列引用取值唯一实现
* 解析顺序 ORDER BY `stripAlias` 语义一致保证同一引用在各子句里等价
* 1. 精确命中行键与引用完全一致 `alias.col` 形态
* 2. 剥离别名前缀`t.n` `n`
* 3. 唯一后缀匹配行键是 `t.n` 而引用写作 `n`
* 4. 以上都不中 `strict` 抛错或返回 `UNRESOLVED`
*
* v0.8.0A25为什么必须有这一个函数
*
* "从行里取一列"此前散落在 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`
* 多个后缀命中视为**歧义**JOIN 里两表同名列`strict` 下抛错
* 静默取第一个正是"结果取决于表顺序"这类难查问题的来源
*/
function resolveColumnValue(row, reference, opts) {
const text = reference.trim();
if (text in row)
return row[text];
// 别名前缀(`t.n` → `n`):JOIN 行用 `alias.col` 作键,单表路径的
// 行键不带前缀,两种形态都必须支持。
// 别名前缀(`t.n` → `n`):JOIN 行用 `alias.col` 作键,单表路径的键不带前缀
if (text.includes('.')) {
const bare = text.split('.').pop();
if (bare in row)
@@ -13019,8 +13032,431 @@ function resolveColumnValue(row, reference, opts) {
? `Ambiguous column "${text}" in ${opts.context}: present in multiple tables`
: `Unknown column "${text}" in ${opts.context}`, 'COLUMN_NOT_FOUND', { column: text });
}
return undefined;
return UNRESOLVED;
}
/**
* 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 统一
*/
// ---------------------------------------------------------------------------
// 解析
// ---------------------------------------------------------------------------
/** 解析缓存:同一段文本只解析一次(纯函数,可安全记忆化) */
const caseParseCache = new Map();
/** 缓存上限(防御性:避免长生命周期进程里无界增长) */
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或条件无法解析
*/
function parseCaseExpression(expr) {
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;
}
/**
* 真正的解析实现
*
* `tokenize` 得到带位置的 token 流后按源码切片 这样"条件/值片段"
* 原始 SQL 逐字符一致含引号与转义可以安全地交给 `parseWhereCondition`
*/
function parseCaseExpressionUncached(text) {
const tokens = tokenize(text);
let i = 0;
const fail = (message, position) => {
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 = [];
let elseText = 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, endIndexExclusive) => {
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;
try {
condition = parseWhereCondition(conditionText);
}
catch (error) {
throw new DatabaseError(`Invalid CASE WHEN condition "${conditionText}": ${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 = 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 的语义一致
*/
function evaluateCase(expr, row) {
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 时是两表并集
*/
function assertCaseColumnsExist(expr, available, context) {
const missing = [];
const checkRef = (ref) => {
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) => {
for (const [key, value] of Object.entries(cond)) {
if (key === '$and' || key === '$or') {
for (const sub of (Array.isArray(value) ? value : [value]))
walkCondition(sub);
continue;
}
if (key === '$not') {
walkCondition(value);
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)) {
if (operand !== null && typeof operand === 'object' && !Array.isArray(operand) && '$col' in operand) {
checkRef(String(operand.$col));
}
}
}
}
};
for (const branch of expr.branches)
walkCondition(branch.condition);
// 结果片段里的列引用(嵌套 CASE 递归)
const checkResult = (text) => {
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"` 变成了用户可见的返回值
*/
function evaluateExpressionValue(text, row) {
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 });
}
/**
* metona-sqlark Query Executor AST 执行器
* @module query/executor
*
* JOIN / GROUP BY / DISTINCT 逻辑在此层处理
*/
// ---------------------------------------------------------------------------
// 分组 / 去重键编码(v0.7.4)
// ---------------------------------------------------------------------------
// 分组 / 去重键
// ---------------------------------------------------------------------------
// v0.8.0B-2):`encodeValueKey` 已删除 —— 它曾是**第四份**值编码实现
// `sql-compare.ts` 的 `encodeValueKey`、Aria 索引键、COUNT(DISTINCT) 各有其一)。
// 四份编码对 null/undefined 的处理各不相同,于是"同两个值在 GROUP BY 相等、
// 在 DISTINCT 不等"这类跨路径矛盾无法根除。现在统一使用 `encodeValueKey`
// 类型前缀 + 长度前缀,null 与 undefined 同为 SQL NULL(合并),
// 且编码不会与数据内容冲突。
// ---------------------------------------------------------------------------
// 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 **完全相同**的解析器。
/**
* 解析聚合函数表达式 **唯一**的聚合识别实现
*
@@ -13121,6 +13557,23 @@ function bareReference(reference) {
return text;
return text.split('.').pop();
}
/**
* 求值一个 GROUP BY 分组项v0.8.0 / B-4
*
* 分组项有两种形态
* - **列引用** `表.列` 走共享的 `resolveColumnValue`
* - **CASE 表达式** 逐行求值`GROUP BY CASE WHEN ... END`
*
* 之所以要这个包装而不是在调用点内联判断分组键在两处用到
*建组时逐行输出分组行时取首行两处必须用**完全相同**的求值规则
* 否则会再次出现"键相同但输出值不同"的漂移
*/
function resolveGroupKeyValue(row, item) {
const caseExpr = parseCaseExpression(item);
if (caseExpr)
return evaluateCase(caseExpr, row);
return resolveColumnValue(row, item, { strict: true, context: 'GROUP BY' });
}
function resolveAliasSource(source, row) {
const text = source.trim();
// 字符串常量(含 SQL 标准 '' 转义还原)
@@ -13173,61 +13626,6 @@ function reduceNumeric(values, op) {
}
return op === 'AVG' ? acc / values.length : acc;
}
/** 解析 "CASE WHEN c1 THEN v1 WHEN c2 THEN v2 ELSE v3 END [AS alias]" */
function parseCaseExpression(expr) {
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 = [];
const re = /WHEN\s+([\s\S]*?)\s+THEN\s+([\s\S]*?)(?=\s+WHEN\s+|\s+ELSE\s+|\s*$)/gi;
let match;
while ((match = re.exec(body)) !== null) {
let cond = null;
try {
cond = parseWhereCondition(match[1].trim());
}
catch {
// 条件解析失败视为不匹配
}
whens.push({ cond, value: match[2].trim() });
}
let elseValue = 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, row) {
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, row) {
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
// ---------------------------------------------------------------------------
@@ -13548,6 +13946,13 @@ class QueryExecutor {
const shape = this.analyzeSelect(stmt);
const { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe } = shape;
let rows;
// v0.8.0B-4):CASE 表达式里的列引用必须在**分组/聚合之前**校验。
//
// 时机很关键:分组会把行替换为"分组键 + 聚合值",此后源列已不存在,
// 任何基于行的校验都会误报未知列(实测:`GROUP BY CASE ... END` 在投影期
// 校验会报 Unknown column "n")。这里按**schema** 建可见列集合,
// 因此与行形状无关,天然正确。
await this.assertCaseColumnsExist(stmt, isJoinQuery);
// v0.8.0A37):WHERE 里的列引用也必须校验存在性。
//
// 投影侧的同类校验(assertProjectionColumnsExist)早已存在,但 WHERE 侧一直
@@ -13982,8 +14387,13 @@ 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, []);
@@ -14000,7 +14410,16 @@ 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 与输出键写入
for (const agg of aggregateExprs) {
@@ -14088,7 +14507,7 @@ 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];
@@ -14114,6 +14533,13 @@ class QueryExecutor {
resolveGroupByColumns(stmt) {
const aliases = new Map();
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());
@@ -14157,7 +14583,7 @@ class QueryExecutor {
*/
computeAggregate(func, rows, arg) {
// 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(别名前缀剥离 + 后缀回退)。
@@ -14546,7 +14972,7 @@ 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;
@@ -14644,6 +15070,48 @@ class QueryExecutor {
+ ` in JOIN ON of table "${stmt.from}"`, 'COLUMN_NOT_FOUND', { columns: missing, from: stmt.from });
}
}
/**
* v0.8.0B-4校验 SELECT / GROUP BY / HAVING / ORDER BY CASE 表达式引用的列存在
*
* 为什么单独一个方法CASE 可以出现在四个子句里而每个子句的校验时机不同
* WHERE `assertWhereColumnsExist`投影有 `assertProjectionColumnsExist`
* 统一在这里按 **schema** 收集可见列与行形状解耦避免"分组后校验不到源列"
*/
async assertCaseColumnsExist(stmt, isJoinQuery) {
if (stmt.fromSubquery)
return; // 派生表列来自子查询投影,需另行解析
if (!stmt.from)
return;
const available = new Set();
const addTable = async (table, alias) => {
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, context) => {
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 中出现的列名都存在于行源
*
@@ -14847,6 +15315,9 @@ class QueryExecutor {
const col = raw.trim();
if (col === '*')
continue;
// CASE 表达式在此跳过:它内部的列引用由 assertCaseColumnsExist 在
// **分组/聚合之前**(仍在源行形状上)单独校验 —— 此处 rows 可能已被
// 分组替换(只剩分组键与聚合值),拿不到源列,会误报未知列。
if (parseCaseExpression(col))
continue;
let reference = col;