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:
Vendored
+589
-118
@@ -11311,58 +11311,69 @@ class Lexer {
|
||||
/** 读取下一个 Token */
|
||||
nextToken() {
|
||||
this.skipWhitespaceAndComments();
|
||||
/**
|
||||
* v0.8.0:token 的**起始位置**在读到任何字符之前记录。
|
||||
*
|
||||
* 为什么必须在这里记:此前的 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 "'":
|
||||
@@ -11379,24 +11390,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;
|
||||
}
|
||||
@@ -11514,7 +11518,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 !== '') {
|
||||
@@ -11534,7 +11542,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,
|
||||
@@ -11549,8 +11557,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 !== '') {
|
||||
@@ -11575,7 +11583,9 @@ class Lexer {
|
||||
return {
|
||||
type: TokenType.QUOTED_IDENTIFIER,
|
||||
value,
|
||||
position: contentStart,
|
||||
// v0.8.0: 与 STRING 一致 —— position = **开引号**的位置(按 position 切片
|
||||
// 才能取到完整的分隔标识符文本)
|
||||
position: start,
|
||||
};
|
||||
}
|
||||
isLetter(ch) {
|
||||
@@ -11584,8 +11594,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 };
|
||||
}
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -12957,43 +12967,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)` 返回 0(A25)、`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` 返回 `[{}]`
|
||||
* (键在、值为 undefined,JSON 序列化后整个键消失),
|
||||
* 而这正是 EXISTS 子查询里最常见的写法(`SELECT 1 FROM ...`)。
|
||||
*/
|
||||
/**
|
||||
* 列引用取值(唯一实现)。
|
||||
* 解析顺序(与 ORDER BY 的 `stripAlias` 语义一致,保证同一引用在各子句里等价):
|
||||
* 1. 精确命中(行键与引用完全一致,含 `alias.col` 形态);
|
||||
* 2. 剥离别名前缀(`t.n` → `n`);
|
||||
* 3. 唯一后缀匹配(行键是 `t.n` 而引用写作 `n`);
|
||||
* 4. 以上都不中 → 依 `strict` 抛错或返回 `UNRESOLVED`。
|
||||
*
|
||||
* v0.8.0(A25):为什么必须有这一个函数。
|
||||
*
|
||||
* "从行里取一列"此前散落在 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)
|
||||
@@ -13015,8 +13028,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.0(B-2):`encodeValueKey` 已删除 —— 它曾是**第四份**值编码实现
|
||||
// (`sql-compare.ts` 的 `encodeValueKey`、Aria 索引键、COUNT(DISTINCT) 各有其一)。
|
||||
// 四份编码对 null/undefined 的处理各不相同,于是"同两个值在 GROUP BY 相等、
|
||||
// 在 DISTINCT 不等"这类跨路径矛盾无法根除。现在统一使用 `encodeValueKey`:
|
||||
// 类型前缀 + 长度前缀,null 与 undefined 同为 SQL NULL(合并),
|
||||
// 且编码不会与数据内容冲突。
|
||||
// ---------------------------------------------------------------------------
|
||||
// CASE WHEN 表达式
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// v0.8.0(B-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 **完全相同**的解析器。
|
||||
/**
|
||||
* 解析聚合函数表达式 —— **唯一**的聚合识别实现。
|
||||
*
|
||||
@@ -13117,6 +13553,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 标准 '' 转义还原)
|
||||
@@ -13169,61 +13622,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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -13544,6 +13942,13 @@ class QueryExecutor {
|
||||
const shape = this.analyzeSelect(stmt);
|
||||
const { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe } = shape;
|
||||
let rows;
|
||||
// v0.8.0(B-4):CASE 表达式里的列引用必须在**分组/聚合之前**校验。
|
||||
//
|
||||
// 时机很关键:分组会把行替换为"分组键 + 聚合值",此后源列已不存在,
|
||||
// 任何基于行的校验都会误报未知列(实测:`GROUP BY CASE ... END` 在投影期
|
||||
// 校验会报 Unknown column "n")。这里按**schema** 建可见列集合,
|
||||
// 因此与行形状无关,天然正确。
|
||||
await this.assertCaseColumnsExist(stmt, isJoinQuery);
|
||||
// v0.8.0(A37):WHERE 里的列引用也必须校验存在性。
|
||||
//
|
||||
// 投影侧的同类校验(assertProjectionColumnsExist)早已存在,但 WHERE 侧一直
|
||||
@@ -13978,8 +14383,13 @@ class QueryExecutor {
|
||||
for (const row of rows) {
|
||||
// v0.7.4: 类型安全键编码 —— 此前 String(row[col] ?? 'null') 使
|
||||
// null 与字符串 'null' 合并为一组(GROUP BY 静默少组)
|
||||
//
|
||||
// v0.8.0(B-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, []);
|
||||
@@ -13996,7 +14406,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) {
|
||||
@@ -14084,7 +14503,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];
|
||||
@@ -14110,6 +14529,13 @@ class QueryExecutor {
|
||||
resolveGroupByColumns(stmt) {
|
||||
const aliases = new Map();
|
||||
for (const col of stmt.columns) {
|
||||
// v0.8.0(B-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());
|
||||
@@ -14153,7 +14579,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.0(A25):列引用统一走 resolveColumnValue(别名前缀剥离 + 后缀回退)。
|
||||
@@ -14542,7 +14968,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;
|
||||
@@ -14640,6 +15066,48 @@ class QueryExecutor {
|
||||
+ ` in JOIN ON of table "${stmt.from}"`, 'COLUMN_NOT_FOUND', { columns: missing, from: stmt.from });
|
||||
}
|
||||
}
|
||||
/**
|
||||
* v0.8.0(B-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.0(A37):校验 WHERE 中出现的列名都存在于行源。
|
||||
*
|
||||
@@ -14843,6 +15311,9 @@ class QueryExecutor {
|
||||
const col = raw.trim();
|
||||
if (col === '*')
|
||||
continue;
|
||||
// CASE 表达式在此跳过:它内部的列引用由 assertCaseColumnsExist 在
|
||||
// **分组/聚合之前**(仍在源行形状上)单独校验 —— 此处 rows 可能已被
|
||||
// 分组替换(只剩分组键与聚合值),拿不到源列,会误报未知列。
|
||||
if (parseCaseExpression(col))
|
||||
continue;
|
||||
let reference = col;
|
||||
|
||||
Reference in New Issue
Block a user