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;
+1 -1
View File
File diff suppressed because one or more lines are too long
+8
View File
@@ -776,6 +776,14 @@ declare class QueryExecutor {
* 裸列名只要求"某一侧存在"(`ON k = k` 的既有语义是取主表列)。
*/
private validateJoinOnColumns;
/**
* v0.8.0B-4):校验 SELECT / GROUP BY / HAVING / ORDER BY 里 CASE 表达式引用的列存在。
*
* 为什么单独一个方法:CASE 可以出现在四个子句里,而每个子句的校验时机不同
* WHERE 有 `assertWhereColumnsExist`,投影有 `assertProjectionColumnsExist`)。
* 统一在这里按 **schema** 收集可见列,与行形状解耦,避免"分组后校验不到源列"。
*/
private assertCaseColumnsExist;
/**
* v0.8.0A37):校验 WHERE 中出现的列名都存在于行源。
*
+589 -118
View File
@@ -11311,58 +11311,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 "'":
@@ -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)` 返回 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)
@@ -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.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 **完全相同**的解析器。
/**
* 解析聚合函数表达式 **唯一**的聚合识别实现
*
@@ -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.0B-4):CASE 表达式里的列引用必须在**分组/聚合之前**校验。
//
// 时机很关键:分组会把行替换为"分组键 + 聚合值",此后源列已不存在,
// 任何基于行的校验都会误报未知列(实测:`GROUP BY CASE ... END` 在投影期
// 校验会报 Unknown column "n")。这里按**schema** 建可见列集合,
// 因此与行形状无关,天然正确。
await this.assertCaseColumnsExist(stmt, isJoinQuery);
// v0.8.0A37):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.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, []);
@@ -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.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());
@@ -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.0A25):列引用统一走 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.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 中出现的列名都存在于行源
*
@@ -14843,6 +15311,9 @@ class QueryExecutor {
const col = raw.trim();
if (col === '*')
continue;
// CASE 表达式在此跳过:它内部的列引用由 assertCaseColumnsExist 在
// **分组/聚合之前**(仍在源行形状上)单独校验 —— 此处 rows 可能已被
// 分组替换(只剩分组键与聚合值),拿不到源列,会误报未知列。
if (parseCaseExpression(col))
continue;
let reference = col;
+1 -1
View File
File diff suppressed because one or more lines are too long
+589 -118
View File
@@ -11317,58 +11317,69 @@
/** 读取下一个 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 "'":
@@ -11385,24 +11396,17 @@
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;
}
@@ -11520,7 +11524,11 @@
* 完全一致且任意以反斜杠结尾的参数值都能正确绑定
*/
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 !== '') {
@@ -11540,7 +11548,7 @@
// 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,
@@ -11555,8 +11563,8 @@
* 未闭合同样显式报错与字符串字面量保持一致
*/
readQuotedIdentifier() {
// v0.8.0: start = 开引号位置(position 语义统一为 token 首字符)
const start = this.position;
const contentStart = this.position + 1;
this.readChar(); // 跳过开始引号
let value = '';
while (this.ch !== '') {
@@ -11581,7 +11589,9 @@
return {
type: TokenType.QUOTED_IDENTIFIER,
value,
position: contentStart,
// v0.8.0: 与 STRING 一致 —— position = **开引号**的位置(按 position 切片
// 才能取到完整的分隔标识符文本)
position: start,
};
}
isLetter(ch) {
@@ -11590,8 +11600,8 @@
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 };
}
}
// ---------------------------------------------------------------------------
@@ -12963,43 +12973,46 @@
}
/**
* 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)
@@ -13021,8 +13034,431 @@
? `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 **完全相同**的解析器。
/**
* 解析聚合函数表达式 **唯一**的聚合识别实现
*
@@ -13123,6 +13559,23 @@
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 标准 '' 转义还原)
@@ -13175,61 +13628,6 @@
}
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
// ---------------------------------------------------------------------------
@@ -13550,6 +13948,13 @@
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 侧一直
@@ -13984,8 +14389,13 @@
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, []);
@@ -14002,7 +14412,16 @@
// 分组列以基列名输出(`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) {
@@ -14090,7 +14509,7 @@
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];
@@ -14116,6 +14535,13 @@
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());
@@ -14159,7 +14585,7 @@
*/
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(别名前缀剥离 + 后缀回退)。
@@ -14548,7 +14974,7 @@
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;
@@ -14646,6 +15072,48 @@
+ ` 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 中出现的列名都存在于行源
*
@@ -14849,6 +15317,9 @@
const col = raw.trim();
if (col === '*')
continue;
// CASE 表达式在此跳过:它内部的列引用由 assertCaseColumnsExist 在
// **分组/聚合之前**(仍在源行形状上)单独校验 —— 此处 rows 可能已被
// 分组替换(只剩分组键与聚合值),拿不到源列,会误报未知列。
if (parseCaseExpression(col))
continue;
let reference = col;
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+89
View File
@@ -0,0 +1,89 @@
/**
* metona-sqlark WHERE / / / ****
* @module query/column-value
*
* ============================================================================
* 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 里的未解析引用""表达式里的未解析引用"
*/
import { DatabaseError } from '../constants';
import { UNRESOLVED } from './sql-compare';
/** 取值选项 */
export interface ResolveOptions {
/**
* `COLUMN_NOT_FOUND`
* - `true` GROUP BY / / "这里必须有一列"
* - `false` `UNRESOLVED` WHERE UNKNOWN
* "行里确实没有该键""该列值为 NULL"
*/
strict: boolean;
/** 错误消息中的位置描述(如 'GROUP BY' / 'aggregate COUNT(n)' / 'SELECT list' */
context: string;
}
/**
*
*
* ORDER BY `stripAlias`
* 1. `alias.col`
* 2. `t.n` `n`
* 3. `t.n` `n`
* 4. `strict` `UNRESOLVED`
*
* ****JOIN `strict`
* "结果取决于表顺序"
*/
export function resolveColumnValue(
row: Record<string, unknown>,
reference: string,
opts: ResolveOptions,
): unknown {
const text = reference.trim();
if (text in row) return row[text];
// 别名前缀(`t.n` → `n`):JOIN 行用 `alias.col` 作键,单表路径的键不带前缀
if (text.includes('.')) {
const bare = text.split('.').pop()!;
if (bare in row) return row[bare];
}
// 唯一后缀匹配:行键 `t.n` 而引用写作 `n`
let found: unknown;
let hits = 0;
for (const key of Object.keys(row)) {
if (key.endsWith(`.${text}`)) {
found = row[key];
hits += 1;
}
}
if (hits === 1) return found;
if (opts.strict) {
throw new DatabaseError(
hits > 1
? `Ambiguous column "${text}" in ${opts.context}: present in multiple tables`
: `Unknown column "${text}" in ${opts.context}`,
'COLUMN_NOT_FOUND',
{ column: text },
);
}
return UNRESOLVED;
}
+107 -135
View File
@@ -16,8 +16,12 @@ import { DatabaseError } from '../constants';
import { compileStatement } from './compiler';
import { createSchema, astColumnToColumnDef } from '../table/schema';
import { matchWhere, applyOrderBy, projectColumns, containsUnresolvedSubqueries } from './where-matcher';
import { parseWhereCondition } from '../sql/parser';
import { encodeValueKey } from './sql-compare';
import {
parseCaseExpression, evaluateCase,
assertCaseColumnsExist, type CaseExpression,
} from './expression';
import { resolveColumnValue } from './column-value';
// ---------------------------------------------------------------------------
// SELECT 执行形态(v0.8.0
@@ -65,85 +69,20 @@ import type { WhereCondition } from '../constants';
// 且编码不会与数据内容冲突。
// ---------------------------------------------------------------------------
// CASE WHEN 表达式v0.3.1
// 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 **完全相同**的解析器。
interface CaseWhenClause {
/** 条件(已解析为 WhereCondition,解析失败为 null 表示跳过) */
cond: WhereCondition | null;
/** THEN 值(字面量或列引用文本) */
value: string;
}
interface CaseExpression {
whens: CaseWhenClause[];
elseValue: string | null;
alias: string | null;
}
/**
* v0.8.0: 解析 `expr AS alias` `expr`
*
* "字符串常量""列引用"//NULL
* `row[source]` undefined `SELECT 1 AS one FROM t` `[{}]`
* undefinedJSON
* EXISTS `SELECT 1 FROM ...`
*/
/**
*
*
* 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`
*/
function resolveColumnValue(
row: Record<string, unknown>,
reference: string,
opts: { strict: boolean; context: string },
): unknown {
const text = reference.trim();
if (text in row) return row[text];
// 别名前缀(`t.n` → `n`):JOIN 行用 `alias.col` 作键,但单表路径的
// 行键不带前缀,两种形态都必须支持。
if (text.includes('.')) {
const bare = text.split('.').pop()!;
if (bare in row) return row[bare];
}
// 唯一后缀匹配:行键 `t.n` 而引用写作 `n`
let found: unknown;
let hits = 0;
for (const key of Object.keys(row)) {
if (key.endsWith(`.${text}`)) {
found = row[key];
hits += 1;
}
}
if (hits === 1) return found;
if (opts.strict) {
throw new DatabaseError(
hits > 1
? `Ambiguous column "${text}" in ${opts.context}: present in multiple tables`
: `Unknown column "${text}" in ${opts.context}`,
'COLUMN_NOT_FOUND',
{ column: text },
);
}
return undefined;
}
/**
* ****
@@ -240,6 +179,23 @@ function bareReference(reference: string): string {
return text.split('.').pop()!;
}
/**
* GROUP BY v0.8.0 / B-4
*
*
* - **** `表.列` `resolveColumnValue`
* - **CASE ** `GROUP BY CASE WHEN ... END`
*
*
*****
* "键相同但输出值不同"
*/
function resolveGroupKeyValue(row: Record<string, unknown>, item: string): unknown {
const caseExpr = parseCaseExpression(item);
if (caseExpr) return evaluateCase(caseExpr, row);
return resolveColumnValue(row, item, { strict: true, context: 'GROUP BY' });
}
function resolveAliasSource(source: string, row: Record<string, unknown>): unknown {
const text = source.trim();
// 字符串常量(含 SQL 标准 '' 转义还原)
@@ -286,58 +242,6 @@ function reduceNumeric(values: number[], op: 'SUM' | 'AVG' | 'MIN' | 'MAX'): num
return op === 'AVG' ? acc / values.length : acc;
}
/** 解析 "CASE WHEN c1 THEN v1 WHEN c2 THEN v2 ELSE v3 END [AS alias]" */
function parseCaseExpression(expr: string): CaseExpression | null {
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: CaseWhenClause[] = [];
const re = /WHEN\s+([\s\S]*?)\s+THEN\s+([\s\S]*?)(?=\s+WHEN\s+|\s+ELSE\s+|\s*$)/gi;
let match: RegExpExecArray | null;
while ((match = re.exec(body)) !== null) {
let cond: WhereCondition | null = null;
try {
cond = parseWhereCondition(match[1].trim());
} catch {
// 条件解析失败视为不匹配
}
whens.push({ cond, value: match[2].trim() });
}
let elseValue: string | null = 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: string, row: Record<string, unknown>): unknown {
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: CaseExpression, row: Record<string, unknown>): unknown {
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
@@ -683,6 +587,14 @@ export class QueryExecutor {
const { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe } = shape;
let rows: Record<string, unknown>[];
// v0.8.0B-4):CASE 表达式里的列引用必须在**分组/聚合之前**校验。
//
// 时机很关键:分组会把行替换为"分组键 + 聚合值",此后源列已不存在,
// 任何基于行的校验都会误报未知列(实测:`GROUP BY CASE ... END` 在投影期
// 校验会报 Unknown column "n")。这里按**schema** 建可见列集合,
// 因此与行形状无关,天然正确。
await this.assertCaseColumnsExist(stmt, isJoinQuery);
// v0.8.0A37):WHERE 里的列引用也必须校验存在性。
//
// 投影侧的同类校验(assertProjectionColumnsExist)早已存在,但 WHERE 侧一直
@@ -1115,8 +1027,13 @@ export 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, []);
groups.get(key)!.push(row);
@@ -1134,7 +1051,16 @@ export 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 与输出键写入
@@ -1222,7 +1148,7 @@ export 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];
@@ -1249,6 +1175,13 @@ export class QueryExecutor {
private resolveGroupByColumns(stmt: SelectStatement): string[] {
const aliases = new Map<string, string>();
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());
}
@@ -1291,7 +1224,7 @@ export class QueryExecutor {
*/
private computeAggregate(func: string, rows: Record<string, unknown>[], arg: string): number | null {
// 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(别名前缀剥离 + 后缀回退)。
@@ -1729,7 +1662,7 @@ export 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;
@@ -1825,6 +1758,42 @@ export class QueryExecutor {
}
}
/**
* v0.8.0B-4 SELECT / GROUP BY / HAVING / ORDER BY CASE
*
* CASE
* WHERE `assertWhereColumnsExist` `assertProjectionColumnsExist`
* **schema** "分组后校验不到源列"
*/
private async assertCaseColumnsExist(stmt: SelectStatement, isJoinQuery: boolean): Promise<void> {
if (stmt.fromSubquery) return; // 派生表列来自子查询投影,需另行解析
if (!stmt.from) return;
const available = new Set<string>();
const addTable = async (table: string, alias: string): Promise<void> => {
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: string, context: string): void => {
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
*
@@ -2032,6 +2001,9 @@ export class QueryExecutor {
for (const raw of columns) {
const col = raw.trim();
if (col === '*') continue;
// CASE 表达式在此跳过:它内部的列引用由 assertCaseColumnsExist 在
// **分组/聚合之前**(仍在源行形状上)单独校验 —— 此处 rows 可能已被
// 分组替换(只剩分组键与聚合值),拿不到源列,会误报未知列。
if (parseCaseExpression(col)) continue;
let reference = col;
+464
View File
@@ -0,0 +1,464 @@
/**
* 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
*/
import { tokenize } from '../sql/lexer';
import { TokenType } from '../sql/tokens';
import { parseWhereCondition } from '../sql/parser';
import type { WhereCondition } from '../constants';
import { DatabaseError } from '../constants';
import { matchWhere } from './where-matcher';
import { isSqlNull, sqlCompareOrder, UNRESOLVED, isUnresolved } from './sql-compare';
import { resolveColumnValue } from './column-value';
// ---------------------------------------------------------------------------
// 结构化 CASE 表达式
// ---------------------------------------------------------------------------
/** 单个 `WHEN <条件> THEN <值>` 分支 */
export interface CaseWhenBranch {
/** 条件的源码片段(已 trim) */
conditionText: string;
/** 值的源码片段(已 trim) */
resultText: string;
/**
* ****
* "cond = null 表示解析失败"
*/
condition: WhereCondition;
}
/** 结构化 CASE 表达式 */
export interface CaseExpression {
/** 分支列表(按出现顺序) */
branches: CaseWhenBranch[];
/** `ELSE <值>` 的源码片段;未写 ELSE 时为 null(求值为 NULL */
elseText: string | null;
/** `... END AS alias` 的别名 */
alias: string | null;
/** 表达式原文(用于输出列名与错误信息) */
source: string;
}
// ---------------------------------------------------------------------------
// 解析
// ---------------------------------------------------------------------------
/** 解析缓存:同一段文本只解析一次(纯函数,可安全记忆化) */
const caseParseCache = new Map<string, CaseExpression>();
/** 缓存上限(防御性:避免长生命周期进程里无界增长) */
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
*/
export function parseCaseExpression(expr: string): CaseExpression | null {
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;
}
/** 清空解析缓存(测试用;schema 变化不影响 CASE 结构,故生产无需清理) */
export function clearCaseParseCache(): void {
caseParseCache.clear();
}
/**
*
*
* `tokenize` token "条件/值片段"
* SQL `parseWhereCondition`
*/
function parseCaseExpressionUncached(text: string): CaseExpression {
const tokens = tokenize(text);
let i = 0;
const fail = (message: string, position: number): never => {
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: CaseWhenBranch[] = [];
let elseText: string | null = 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: number, endIndexExclusive: number): string => {
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: WhereCondition;
try {
condition = parseWhereCondition(conditionText);
} catch (error) {
throw new DatabaseError(
`Invalid CASE WHEN condition "${conditionText}": ${(error as 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: string | null = 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
*/
export function evaluateCase(
expr: CaseExpression,
row: Record<string, unknown>,
): unknown {
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
*/
export function assertCaseColumnsExist(
expr: CaseExpression,
available: ReadonlySet<string>,
context: string,
): void {
const missing: string[] = [];
const checkRef = (ref: string): void => {
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: WhereCondition): void => {
for (const [key, value] of Object.entries(cond)) {
if (key === '$and' || key === '$or') {
for (const sub of (Array.isArray(value) ? value : [value]) as WhereCondition[]) walkCondition(sub);
continue;
}
if (key === '$not') { walkCondition(value as WhereCondition); 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 as Record<string, unknown>)) {
if (operand !== null && typeof operand === 'object' && !Array.isArray(operand) && '$col' in (operand as object)) {
checkRef(String((operand as Record<string, unknown>).$col));
}
}
}
}
};
for (const branch of expr.branches) walkCondition(branch.condition);
// 结果片段里的列引用(嵌套 CASE 递归)
const checkResult = (text: string): void => {
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"`
*/
export function evaluateExpressionValue(
text: string,
row: Record<string, unknown>,
): unknown {
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 },
);
}
/** 判断文本是否为 CASE 表达式(供 executor 分流,避免到处写正则) */
export function isCaseExpression(text: string): boolean {
return /^\s*CASE\b/i.test(text);
}
/**
* CASE `CASE <表达式> WHEN <值> THEN ...`
*
* ** CASE**`CASE WHEN <条件> THEN ...` CASE
* `CASE x WHEN 1 THEN`
* NOT_SUPPORTED"看起来支持、结果全错"
*/
export function assertNoSimpleCaseForm(expr: CaseExpression, tokensText: string): void {
if (/^\s*CASE\s+(?!WHEN\b)/i.test(tokensText)) {
throw new DatabaseError(
'Simple CASE form (CASE <expr> WHEN <value>) is not supported; use searched CASE'
+ ' (CASE WHEN <condition> THEN <result> ... END) instead',
'NOT_SUPPORTED',
);
}
void expr;
}
/** 供 executor 判断比较顺序的工具(保留三值语义入口,避免各处重复实现) */
export function compareForCase(a: unknown, b: unknown): number | null {
if (isSqlNull(a) || isSqlNull(b) || isUnresolved(a) || isUnresolved(b)) return null;
return sqlCompareOrder(a, b);
}
/** 供调用方复用的哨兵(避免重复 import sql-compare */
export { UNRESOLVED };
+41 -30
View File
@@ -27,55 +27,67 @@ export class Lexer {
nextToken(): Token {
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: Token;
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 "'":
@@ -92,22 +104,15 @@ export 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;
}
@@ -235,7 +240,11 @@ export class Lexer {
*
*/
private readString(): Token {
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 = '';
@@ -258,7 +267,7 @@ export class Lexer {
// 上层可解析出错误结果,如 `SELECT 'abc` 被当作合法常量列)
if (this.ch === '') {
throw new DatabaseError(
`Unterminated string literal at position ${start}`,
`Unterminated string literal at position ${contentStart}`,
'PARSE_ERROR',
);
}
@@ -277,8 +286,8 @@ export class Lexer {
*
*/
private readQuotedIdentifier(): Token {
// v0.8.0: start = 开引号位置(position 语义统一为 token 首字符)
const start = this.position;
const contentStart = this.position + 1;
this.readChar(); // 跳过开始引号
let value = '';
@@ -313,7 +322,9 @@ export class Lexer {
return {
type: TokenType.QUOTED_IDENTIFIER,
value,
position: contentStart,
// v0.8.0: 与 STRING 一致 —— position = **开引号**的位置(按 position 切片
// 才能取到完整的分隔标识符文本)
position: start,
};
}
@@ -325,8 +336,8 @@ export class Lexer {
return /[0-9]/.test(ch);
}
private makeToken(type: TokenType, value: string): Token {
return { type, value, position: this.position };
private makeToken(type: TokenType, value: string, start: number = this.position): Token {
return { type, value, position: start };
}
}
+321
View File
@@ -0,0 +1,321 @@
/**
* 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']);
});
});
});