feat(B-4): 统一表达式求值 —— CASE 结构化解析(消除正则切分整类缺陷)
修复前 `parseCaseExpression` 用正则切分 WHEN/THEN/ELSE,不认字符串字面量与嵌套,
实测出三类错误结果:
① **嵌套 CASE 返回字符串残片**
`CASE WHEN n>10 THEN CASE WHEN n>25 THEN 'huge' ELSE 'big' END ELSE 'small' END`
→ 实测返回 `"big' END ELSE 'small"`(正则把嵌套 CASE 的 ELSE 当成自己的分支
边界,残片被原样返回给用户)。修复后正确返回 huge/big/small。
② **条件引用不存在的列时静默错值**
`CASE WHEN nope > 1 THEN 'x' ELSE 'y' END` → 每行都是 'y' 且**无任何报错**
(`cond = null` 表示"解析失败"→ 静默跳过分支),而同一个 nope 写在 WHERE 里
会正常抛 COLUMN_NOT_FOUND。修复后统一报 COLUMN_NOT_FOUND。
③ **`GROUP BY CASE ... END` 完全不可用**
→ `COLUMN_NOT_FOUND Unknown column "CASE WHEN n>10 THEN 'big' ELSE 'small' END"`
(分组把 CASE 原文当成列名)。而"按条件分组"是 SQL 最常见的分析写法之一。
修复后正常输出 `[{band:'big',c:2},{band:'small',c:2}]`。
实现(新增 `src/query/expression.ts`):
- 复用 `sql/lexer` 的 token 流做**递归下降**(天然支持嵌套;字符串里的
WHEN/ELSE/THEN 由词法层天然隔离,不可能被当作切分点);
- 条件按源码切片后交给与 WHERE **完全相同**的 `parseWhereCondition` —— 语义同源;
- 结果表达式显式支持字面量/列引用/嵌套 CASE,无法识别的**抛 NOT_SUPPORTED**
(不再把原文当字符串返回);
- 解析结果记忆化(同一表达式在 N 行上只解析一次);
- 新增 `assertCaseColumnsExist`,在**分组/聚合之前**按 schema 校验 CASE 里引用的列
(时机很关键:分组会把行替换为"分组键+聚合值",此后任何基于行的校验都会
误报未知列 —— 这一点在实现中踩到并已修正)。
顺带修掉的**词法层**缺陷:`Token.position` 语义按 token 类型不一致 ——
`readString` 用 `position + 1`(指向引号**之内**),`readIdentifier`/`readNumber`
用 `position - len`(指向首字符)。任何"按 position 切片"的调用方都会对字符串
切错一个字符(实测 `'big'` 被切成 `"big'"`,导致 CASE 全部报 NOT_SUPPORTED)。
现统一为"token 首字符在源码中的下标",并用"从 position 重新词法化应得到同一
token"作为可判定判据加入回归测试。
验证:新增 tests/v080-case-expression.test.ts(54 项:词法层 2 + 解析层 8 +
求值层 5 + 列校验 2 + 四引擎端到端 37),并做**变异验证**:去掉嵌套 CASE 感知后
7 项立即失败(含四引擎的端到端断言),恢复后全绿。
全量 91 套件 / 1807 测试通过;typecheck、lint、build 零错误零告警;dist 已重建。
This commit is contained in:
Vendored
+589
-118
@@ -11315,58 +11315,69 @@ class Lexer {
|
|||||||
/** 读取下一个 Token */
|
/** 读取下一个 Token */
|
||||||
nextToken() {
|
nextToken() {
|
||||||
this.skipWhitespaceAndComments();
|
this.skipWhitespaceAndComments();
|
||||||
|
/**
|
||||||
|
* v0.8.0:token 的**起始位置**在读到任何字符之前记录。
|
||||||
|
*
|
||||||
|
* 为什么必须在这里记:此前的 position 由各 reader 自行回推,语义**按 token
|
||||||
|
* 类型不一致** —— `readString` 用 `start = this.position + 1`(指向引号**之内**),
|
||||||
|
* 而 `readIdentifier`/`readNumber` 用 `this.position - len`(指向首字符)。
|
||||||
|
* 于是 `'big'` 报 position 23 而实际从 24 开始,任何"按 position 切片"的
|
||||||
|
* 调用方都会多切一个字符(实测:CASE 的 THEN 值被切成 `"big'"`)。
|
||||||
|
* 这里统一为"token 首字符在源码中的下标",所有类型一致。
|
||||||
|
*/
|
||||||
|
const tokenStart = this.position;
|
||||||
let tok;
|
let tok;
|
||||||
switch (this.ch) {
|
switch (this.ch) {
|
||||||
case ',':
|
case ',':
|
||||||
tok = this.makeToken(TokenType.COMMA, ',');
|
tok = this.makeToken(TokenType.COMMA, ',', tokenStart);
|
||||||
break;
|
break;
|
||||||
case '(':
|
case '(':
|
||||||
tok = this.makeToken(TokenType.LPAREN, '(');
|
tok = this.makeToken(TokenType.LPAREN, '(', tokenStart);
|
||||||
break;
|
break;
|
||||||
case ')':
|
case ')':
|
||||||
tok = this.makeToken(TokenType.RPAREN, ')');
|
tok = this.makeToken(TokenType.RPAREN, ')', tokenStart);
|
||||||
break;
|
break;
|
||||||
case ';':
|
case ';':
|
||||||
tok = this.makeToken(TokenType.SEMICOLON, ';');
|
tok = this.makeToken(TokenType.SEMICOLON, ';');
|
||||||
break;
|
break;
|
||||||
case '*':
|
case '*':
|
||||||
tok = this.makeToken(TokenType.STAR, '*');
|
tok = this.makeToken(TokenType.STAR, '*', tokenStart);
|
||||||
break;
|
break;
|
||||||
case '.':
|
case '.':
|
||||||
tok = this.makeToken(TokenType.DOT, '.');
|
tok = this.makeToken(TokenType.DOT, '.', tokenStart);
|
||||||
break;
|
break;
|
||||||
case '=':
|
case '=':
|
||||||
tok = this.makeToken(TokenType.EQ, '=');
|
tok = this.makeToken(TokenType.EQ, '=', tokenStart);
|
||||||
break;
|
break;
|
||||||
case '!':
|
case '!':
|
||||||
if (this.peekChar() === '=') {
|
if (this.peekChar() === '=') {
|
||||||
this.readChar();
|
this.readChar();
|
||||||
tok = this.makeToken(TokenType.NEQ, '!=');
|
tok = this.makeToken(TokenType.NEQ, '!=', tokenStart);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
tok = this.makeToken(TokenType.ILLEGAL, '!');
|
tok = this.makeToken(TokenType.ILLEGAL, '!', tokenStart);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case '>':
|
case '>':
|
||||||
if (this.peekChar() === '=') {
|
if (this.peekChar() === '=') {
|
||||||
this.readChar();
|
this.readChar();
|
||||||
tok = this.makeToken(TokenType.GTE, '>=');
|
tok = this.makeToken(TokenType.GTE, '>=', tokenStart);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
tok = this.makeToken(TokenType.GT, '>');
|
tok = this.makeToken(TokenType.GT, '>', tokenStart);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case '<':
|
case '<':
|
||||||
if (this.peekChar() === '=') {
|
if (this.peekChar() === '=') {
|
||||||
this.readChar();
|
this.readChar();
|
||||||
tok = this.makeToken(TokenType.LTE, '<=');
|
tok = this.makeToken(TokenType.LTE, '<=', tokenStart);
|
||||||
}
|
}
|
||||||
else if (this.peekChar() === '>') {
|
else if (this.peekChar() === '>') {
|
||||||
this.readChar();
|
this.readChar();
|
||||||
tok = this.makeToken(TokenType.NEQ, '<>');
|
tok = this.makeToken(TokenType.NEQ, '<>', tokenStart);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
tok = this.makeToken(TokenType.LT, '<');
|
tok = this.makeToken(TokenType.LT, '<', tokenStart);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "'":
|
case "'":
|
||||||
@@ -11383,24 +11394,17 @@ class Lexer {
|
|||||||
if (this.isLetter(this.ch)) {
|
if (this.isLetter(this.ch)) {
|
||||||
const ident = this.readIdentifier();
|
const ident = this.readIdentifier();
|
||||||
const keyword = KEYWORDS[ident.toUpperCase()];
|
const keyword = KEYWORDS[ident.toUpperCase()];
|
||||||
tok = {
|
// v0.8.0: 用 tokenStart(首字符下标),不再用 position - len 回推
|
||||||
type: keyword ?? TokenType.IDENTIFIER,
|
tok = { type: keyword ?? TokenType.IDENTIFIER, value: ident, position: tokenStart };
|
||||||
value: ident,
|
|
||||||
position: this.position - ident.length,
|
|
||||||
};
|
|
||||||
return tok; // 已读取完毕,不需要再 readChar
|
return tok; // 已读取完毕,不需要再 readChar
|
||||||
}
|
}
|
||||||
else if (this.isDigit(this.ch) || (this.ch === '-' && this.isDigit(this.peekChar()))) {
|
else if (this.isDigit(this.ch) || (this.ch === '-' && this.isDigit(this.peekChar()))) {
|
||||||
const num = this.readNumber();
|
const num = this.readNumber();
|
||||||
tok = {
|
tok = { type: TokenType.NUMBER, value: num, position: tokenStart };
|
||||||
type: TokenType.NUMBER,
|
|
||||||
value: num,
|
|
||||||
position: this.position - num.length,
|
|
||||||
};
|
|
||||||
return tok;
|
return tok;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
tok = this.makeToken(TokenType.ILLEGAL, this.ch);
|
tok = this.makeToken(TokenType.ILLEGAL, this.ch, tokenStart);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -11518,7 +11522,11 @@ class Lexer {
|
|||||||
* 完全一致,且任意以反斜杠结尾的参数值都能正确绑定。
|
* 完全一致,且任意以反斜杠结尾的参数值都能正确绑定。
|
||||||
*/
|
*/
|
||||||
readString() {
|
readString() {
|
||||||
const start = this.position + 1; // 跳过一个引号
|
// v0.8.0: start = **开引号**的位置(此前 +1 指向引号之内,导致所有按
|
||||||
|
// position 切片的调用方都多切一个字符)。未闭合错误消息里用的 start 仍取
|
||||||
|
// 引号之后的位置,便于用户定位到内容起点。
|
||||||
|
const start = this.position;
|
||||||
|
const contentStart = this.position + 1;
|
||||||
this.readChar(); // 跳过开始引号
|
this.readChar(); // 跳过开始引号
|
||||||
let value = '';
|
let value = '';
|
||||||
while (this.ch !== '') {
|
while (this.ch !== '') {
|
||||||
@@ -11538,7 +11546,7 @@ class Lexer {
|
|||||||
// v0.7.2: 未闭合字符串字面量显式报错(此前静默返回残缺 STRING token,
|
// v0.7.2: 未闭合字符串字面量显式报错(此前静默返回残缺 STRING token,
|
||||||
// 上层可解析出错误结果,如 `SELECT 'abc` 被当作合法常量列)
|
// 上层可解析出错误结果,如 `SELECT 'abc` 被当作合法常量列)
|
||||||
if (this.ch === '') {
|
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 {
|
return {
|
||||||
type: TokenType.STRING,
|
type: TokenType.STRING,
|
||||||
@@ -11553,8 +11561,8 @@ class Lexer {
|
|||||||
* 未闭合同样显式报错,与字符串字面量保持一致。
|
* 未闭合同样显式报错,与字符串字面量保持一致。
|
||||||
*/
|
*/
|
||||||
readQuotedIdentifier() {
|
readQuotedIdentifier() {
|
||||||
|
// v0.8.0: start = 开引号位置(position 语义统一为 token 首字符)
|
||||||
const start = this.position;
|
const start = this.position;
|
||||||
const contentStart = this.position + 1;
|
|
||||||
this.readChar(); // 跳过开始引号
|
this.readChar(); // 跳过开始引号
|
||||||
let value = '';
|
let value = '';
|
||||||
while (this.ch !== '') {
|
while (this.ch !== '') {
|
||||||
@@ -11579,7 +11587,9 @@ class Lexer {
|
|||||||
return {
|
return {
|
||||||
type: TokenType.QUOTED_IDENTIFIER,
|
type: TokenType.QUOTED_IDENTIFIER,
|
||||||
value,
|
value,
|
||||||
position: contentStart,
|
// v0.8.0: 与 STRING 一致 —— position = **开引号**的位置(按 position 切片
|
||||||
|
// 才能取到完整的分隔标识符文本)
|
||||||
|
position: start,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
isLetter(ch) {
|
isLetter(ch) {
|
||||||
@@ -11588,8 +11598,8 @@ class Lexer {
|
|||||||
isDigit(ch) {
|
isDigit(ch) {
|
||||||
return /[0-9]/.test(ch);
|
return /[0-9]/.test(ch);
|
||||||
}
|
}
|
||||||
makeToken(type, value) {
|
makeToken(type, value, start = this.position) {
|
||||||
return { type, value, position: this.position };
|
return { type, value, position: start };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -12961,43 +12971,46 @@ function parseWhereCondition(sql) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* metona-sqlark Query Executor — AST 执行器
|
* metona-sqlark 列引用取值 —— WHERE / 投影 / 聚合 / 表达式**共用**的唯一实现
|
||||||
* @module query/executor
|
* @module query/column-value
|
||||||
*
|
*
|
||||||
* JOIN / GROUP BY / DISTINCT 逻辑在此层处理。
|
* ============================================================================
|
||||||
|
* 为什么必须只有一个实现(PLAN-v0.7.5.md 根因 1)
|
||||||
|
* ============================================================================
|
||||||
|
* "从一行里按名字取一列"曾是四处各写一份的实现,规则各不相同:
|
||||||
|
*
|
||||||
|
* | 位置 | 别名前缀 | 后缀回退 | 未知列 |
|
||||||
|
* |---|---|---|---|
|
||||||
|
* | `projectColumns`(where-matcher) | 否 | 唯一后缀 | 静默丢键 |
|
||||||
|
* | `resolveAliasSource`(executor) | **否** | 否 | 静默 undefined |
|
||||||
|
* | 聚合参数(executor) | 否 | 否 | **静默计 0**(A25) |
|
||||||
|
* | `matchWhere` 的 `resolveField` | 否 | 唯一后缀(多个则 UNRESOLVED) | UNRESOLVED |
|
||||||
|
*
|
||||||
|
* 于是 `COUNT(t.n)` 返回 0(A25)、`SELECT d.id FROM (...) AS d` 返回空集(A36)、
|
||||||
|
* CASE 的 THEN 分支引用列名时行为随调用点变化(B-4)。
|
||||||
|
*
|
||||||
|
* 现在四处都调用本模块的 `resolveColumnValue`:
|
||||||
|
* - JOIN 行的键是 `alias.col`,单表行的键是 `col` —— 两种形态都要支持;
|
||||||
|
* - 取不到值时**由调用方**决定是抛错还是返回 UNRESOLVED(`strict` 选项),
|
||||||
|
* 因为"WHERE 里的未解析引用"与"表达式里的未解析引用"需要不同的上层处理。
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* v0.8.0: 解析 `expr AS alias` 中的 `expr` 取值来源。
|
* 从行里取一个列引用。
|
||||||
*
|
*
|
||||||
* 此前只处理"字符串常量"与"列引用"两种情况,数字/布尔/NULL 常量会走
|
* 解析顺序(与 ORDER BY 的 `stripAlias` 语义一致,保证同一引用在各子句里等价):
|
||||||
* `row[source]` → undefined,于是 `SELECT 1 AS one FROM t` 返回 `[{}]`
|
* 1. 精确命中(行键与引用完全一致,含 `alias.col` 形态);
|
||||||
* (键在、值为 undefined,JSON 序列化后整个键消失),
|
* 2. 剥离别名前缀(`t.n` → `n`);
|
||||||
* 而这正是 EXISTS 子查询里最常见的写法(`SELECT 1 FROM ...`)。
|
* 3. 唯一后缀匹配(行键是 `t.n` 而引用写作 `n`);
|
||||||
*/
|
* 4. 以上都不中 → 依 `strict` 抛错或返回 `UNRESOLVED`。
|
||||||
/**
|
|
||||||
* 列引用取值(唯一实现)。
|
|
||||||
*
|
*
|
||||||
* v0.8.0(A25):为什么必须有这一个函数。
|
* 多个后缀命中视为**歧义**(JOIN 里两表同名列),`strict` 下抛错 ——
|
||||||
*
|
* 静默取第一个正是"结果取决于表顺序"这类难查问题的来源。
|
||||||
* "从行里取一列"此前散落在 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, reference, opts) {
|
function resolveColumnValue(row, reference, opts) {
|
||||||
const text = reference.trim();
|
const text = reference.trim();
|
||||||
if (text in row)
|
if (text in row)
|
||||||
return row[text];
|
return row[text];
|
||||||
// 别名前缀(`t.n` → `n`):JOIN 行用 `alias.col` 作键,但单表路径的
|
// 别名前缀(`t.n` → `n`):JOIN 行用 `alias.col` 作键,单表路径的键不带前缀
|
||||||
// 行键不带前缀,两种形态都必须支持。
|
|
||||||
if (text.includes('.')) {
|
if (text.includes('.')) {
|
||||||
const bare = text.split('.').pop();
|
const bare = text.split('.').pop();
|
||||||
if (bare in row)
|
if (bare in row)
|
||||||
@@ -13019,8 +13032,431 @@ function resolveColumnValue(row, reference, opts) {
|
|||||||
? `Ambiguous column "${text}" in ${opts.context}: present in multiple tables`
|
? `Ambiguous column "${text}" in ${opts.context}: present in multiple tables`
|
||||||
: `Unknown column "${text}" in ${opts.context}`, 'COLUMN_NOT_FOUND', { column: text });
|
: `Unknown column "${text}" in ${opts.context}`, 'COLUMN_NOT_FOUND', { column: text });
|
||||||
}
|
}
|
||||||
return undefined;
|
return UNRESOLVED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* metona-sqlark 表达式解析与求值 —— CASE WHEN 的结构化实现(v0.8.0 / B-4)
|
||||||
|
* @module query/expression
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* 为什么必须换掉原实现(PLAN-v0.7.5.md 根因 3:字符串化 AST 表达式列)
|
||||||
|
* ============================================================================
|
||||||
|
* 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* /WHEN\s+([\s\S]*?)\s+THEN\s+([\s\S]*?)(?=\s+WHEN\s+|\s+ELSE\s+|\s*$)/gi
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 它不认字符串字面量、不认嵌套结构,于是实测出三类错误结果:
|
||||||
|
*
|
||||||
|
* | 输入 | 实测(修复前) | 应有 |
|
||||||
|
* |---|---|---|
|
||||||
|
* | `CASE WHEN n>10 THEN CASE WHEN n>25 THEN 'huge' ELSE 'big' END ELSE 'small' END` | `"big' END ELSE 'small"` / `null` | `huge`/`big`/`small` |
|
||||||
|
* | `CASE WHEN s='WHEN' THEN 'hit' ELSE 'miss' END` | 依赖切分点,可能错 | `miss/hit/miss` |
|
||||||
|
*
|
||||||
|
* 更严重的是**静默错值**:条件解析失败(如引用不存在的列)时 `cond = null`,
|
||||||
|
* 求值直接跳过该分支 —— 整列变成 ELSE 值,没有任何提示;而同一列名出现在
|
||||||
|
* WHERE 里会正常报错。同一语义两套行为。
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* 本模块的做法
|
||||||
|
* ============================================================================
|
||||||
|
* 1. **复用 `sql/lexer` 的 token 流**(带 `position`),不另写一套词法规则 ——
|
||||||
|
* 字符串里的 `WHEN`/`ELSE`、转义引号、注释都由它正确处理,
|
||||||
|
* 因此"切分点"不再可能落在字面量内部。
|
||||||
|
* 2. **递归下降**解析 CASE(天然支持嵌套),并把每个片段按**源码位置切片**,
|
||||||
|
* 交给既有的 `parseWhereCondition` / 字面量解析器处理 ——
|
||||||
|
* 条件语义与 WHERE 完全同源,不再各写一份。
|
||||||
|
* 3. **解析失败即报错**(`PARSE_ERROR` / `COLUMN_NOT_FOUND`),不静默降级为
|
||||||
|
* 某一个分支的值。
|
||||||
|
* 4. 解析结果**缓存**:`parseCaseExpression` 是纯函数,可安全记忆化;
|
||||||
|
* 聚合与逐行投影会对同一表达式反复求值(N 行 × M 次),缓存把解析开销
|
||||||
|
* 从 O(行数) 降到 O(1)。
|
||||||
|
*
|
||||||
|
* 注意:本模块只负责 **CASE** 表达式。普通列引用/字面量/聚合由
|
||||||
|
* `query/executor` 的 `resolveColumnValue` 与 `parseAggregateExpression`
|
||||||
|
* 处理(B-4 的另一半已由 A25 统一)。
|
||||||
|
*/
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 解析
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/** 解析缓存:同一段文本只解析一次(纯函数,可安全记忆化) */
|
||||||
|
const caseParseCache = new Map();
|
||||||
|
/** 缓存上限(防御性:避免长生命周期进程里无界增长) */
|
||||||
|
const CASE_CACHE_LIMIT = 512;
|
||||||
|
/**
|
||||||
|
* 解析 CASE WHEN 表达式文本。
|
||||||
|
*
|
||||||
|
* @param expr 形如 `CASE WHEN a > 1 THEN 'x' ELSE 'y' END AS band` 的片段
|
||||||
|
* @returns 结构化表达式;**不是** CASE 表达式时返回 null(调用方据此走其它分支)
|
||||||
|
* @throws DatabaseError 结构不完整(缺 THEN/END)或条件无法解析
|
||||||
|
*/
|
||||||
|
function parseCaseExpression(expr) {
|
||||||
|
const text = expr.trim();
|
||||||
|
if (!/^\s*CASE\b/i.test(text))
|
||||||
|
return null;
|
||||||
|
const cached = caseParseCache.get(text);
|
||||||
|
if (cached)
|
||||||
|
return cached;
|
||||||
|
const parsed = parseCaseExpressionUncached(text);
|
||||||
|
// 先清理再写入:条目数达到上限时整表清空,避免无界增长。
|
||||||
|
// 用 LRU 会引入额外状态;解析本身是纯函数且调用点集中在少数表达式上,
|
||||||
|
// "清空重建"足够且没有正确性风险。
|
||||||
|
if (caseParseCache.size >= CASE_CACHE_LIMIT)
|
||||||
|
caseParseCache.clear();
|
||||||
|
caseParseCache.set(text, parsed);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 真正的解析实现。
|
||||||
|
*
|
||||||
|
* 用 `tokenize` 得到带位置的 token 流后按源码切片 —— 这样"条件/值片段"与
|
||||||
|
* 原始 SQL 逐字符一致(含引号与转义),可以安全地交给 `parseWhereCondition`。
|
||||||
|
*/
|
||||||
|
function parseCaseExpressionUncached(text) {
|
||||||
|
const tokens = tokenize(text);
|
||||||
|
let i = 0;
|
||||||
|
const fail = (message, position) => {
|
||||||
|
throw new DatabaseError(`${message} (at offset ${position} in "${text}")`, 'PARSE_ERROR');
|
||||||
|
};
|
||||||
|
// CASE
|
||||||
|
if (tokens[i]?.type !== TokenType.CASE)
|
||||||
|
fail('Expected CASE', tokens[i]?.position ?? 0);
|
||||||
|
i++;
|
||||||
|
const branches = [];
|
||||||
|
let elseText = null;
|
||||||
|
/**
|
||||||
|
* 按源码位置取片段。
|
||||||
|
*
|
||||||
|
* 边界必须用"**下一个 token 的起始位置**"作为右开区间:
|
||||||
|
* STRING token 的 `position` 指向**引号之内**(lexer 的 readString 里
|
||||||
|
* `start = position + 1`),用 `position + value.length` 之类算术会把
|
||||||
|
* 结尾引号切掉(实测:`'small'` 被切成 `"small'"`,CASE 全部报
|
||||||
|
* NOT_SUPPORTED —— 这正是本函数必须存在的理由)。
|
||||||
|
* 同理 NUMBER/IDENTIFIER 的 position 也由各自 reader 回推,语义不完全统一;
|
||||||
|
* 只有"下一个 token 的起点"是对所有 token 类型都成立的边界。
|
||||||
|
*/
|
||||||
|
const slice = (startIndex, endIndexExclusive) => {
|
||||||
|
const startTok = tokens[startIndex];
|
||||||
|
if (!startTok)
|
||||||
|
return '';
|
||||||
|
const endTok = tokens[endIndexExclusive];
|
||||||
|
const endPos = endTok ? endTok.position : text.length;
|
||||||
|
return text.slice(startTok.position, endPos).trim();
|
||||||
|
};
|
||||||
|
while (i < tokens.length) {
|
||||||
|
const tok = tokens[i];
|
||||||
|
if (tok.type === TokenType.WHEN) {
|
||||||
|
const condStart = i + 1;
|
||||||
|
// 找与之配对的 THEN:跳过嵌套的括号(CASE 内的子查询/括号表达式)
|
||||||
|
let depth = 0;
|
||||||
|
let thenIndex = -1;
|
||||||
|
for (let j = condStart; j < tokens.length; j++) {
|
||||||
|
const t = tokens[j];
|
||||||
|
if (t.type === TokenType.LPAREN)
|
||||||
|
depth++;
|
||||||
|
else if (t.type === TokenType.RPAREN)
|
||||||
|
depth--;
|
||||||
|
else if (depth === 0 && t.type === TokenType.THEN) {
|
||||||
|
thenIndex = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (depth === 0 && (t.type === TokenType.ELSE || t.type === TokenType.END))
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (thenIndex < 0)
|
||||||
|
fail('CASE WHEN without matching THEN', tok.position);
|
||||||
|
const valueStart = thenIndex + 1;
|
||||||
|
// 值的结束点:下一个同级 WHEN / ELSE / END
|
||||||
|
let depth2 = 0;
|
||||||
|
let valueEnd = tokens.length;
|
||||||
|
for (let j = valueStart; j < tokens.length; j++) {
|
||||||
|
const t = tokens[j];
|
||||||
|
if (t.type === TokenType.LPAREN)
|
||||||
|
depth2++;
|
||||||
|
else if (t.type === TokenType.RPAREN)
|
||||||
|
depth2--;
|
||||||
|
else if (depth2 === 0 && (t.type === TokenType.WHEN || t.type === TokenType.ELSE || t.type === TokenType.END)) {
|
||||||
|
valueEnd = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (depth2 === 0 && t.type === TokenType.CASE) {
|
||||||
|
// 嵌套 CASE 作为一个整体:跳到与它配对的 END
|
||||||
|
let nestedDepth = 1;
|
||||||
|
for (let k = j + 1; k < tokens.length; k++) {
|
||||||
|
if (tokens[k].type === TokenType.CASE)
|
||||||
|
nestedDepth++;
|
||||||
|
else if (tokens[k].type === TokenType.END) {
|
||||||
|
nestedDepth--;
|
||||||
|
if (nestedDepth === 0) {
|
||||||
|
j = k;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const conditionText = slice(condStart, thenIndex);
|
||||||
|
const resultText = slice(valueStart, valueEnd);
|
||||||
|
if (!conditionText)
|
||||||
|
fail('CASE WHEN has an empty condition', tok.position);
|
||||||
|
if (!resultText)
|
||||||
|
fail('CASE THEN has an empty result', tokens[valueStart]?.position ?? tok.position);
|
||||||
|
// 条件在**解析期**交给与 WHERE 完全相同的解析器 —— 语义同源,
|
||||||
|
// 且"引用不存在的列"之类问题按 WHERE 的口径处理(不再静默跳过分支)。
|
||||||
|
let condition;
|
||||||
|
try {
|
||||||
|
condition = parseWhereCondition(conditionText);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
throw new DatabaseError(`Invalid CASE WHEN condition "${conditionText}": ${error.message}`, 'PARSE_ERROR', { condition: conditionText });
|
||||||
|
}
|
||||||
|
branches.push({ conditionText, resultText, condition });
|
||||||
|
i = valueEnd;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tok.type === TokenType.ELSE) {
|
||||||
|
const elseStart = i + 1;
|
||||||
|
let depth = 0;
|
||||||
|
let elseEnd = tokens.length;
|
||||||
|
for (let j = elseStart; j < tokens.length; j++) {
|
||||||
|
const t = tokens[j];
|
||||||
|
if (t.type === TokenType.LPAREN)
|
||||||
|
depth++;
|
||||||
|
else if (t.type === TokenType.RPAREN)
|
||||||
|
depth--;
|
||||||
|
else if (depth === 0 && t.type === TokenType.END) {
|
||||||
|
elseEnd = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (depth === 0 && t.type === TokenType.CASE) {
|
||||||
|
let nestedDepth = 1;
|
||||||
|
for (let k = j + 1; k < tokens.length; k++) {
|
||||||
|
if (tokens[k].type === TokenType.CASE)
|
||||||
|
nestedDepth++;
|
||||||
|
else if (tokens[k].type === TokenType.END) {
|
||||||
|
nestedDepth--;
|
||||||
|
if (nestedDepth === 0) {
|
||||||
|
j = k;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseText = slice(elseStart, elseEnd);
|
||||||
|
if (!elseText)
|
||||||
|
fail('CASE ELSE has an empty result', tok.position);
|
||||||
|
i = elseEnd;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tok.type === TokenType.END) {
|
||||||
|
i++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
fail(`Unexpected token "${tok.value}" in CASE`, tok.position);
|
||||||
|
}
|
||||||
|
if (branches.length === 0) {
|
||||||
|
throw new DatabaseError(`CASE expression has no WHEN branch: "${text}"`, 'PARSE_ERROR');
|
||||||
|
}
|
||||||
|
// 可选别名:END AS alias / END alias
|
||||||
|
let alias = null;
|
||||||
|
const rest = tokens.slice(i).filter((t) => t.type !== TokenType.EOF && t.type !== TokenType.SEMICOLON);
|
||||||
|
if (rest.length > 0) {
|
||||||
|
const first = rest[0];
|
||||||
|
if (first.type === TokenType.AS) {
|
||||||
|
const aliasTok = rest[1];
|
||||||
|
if (aliasTok && (aliasTok.type === TokenType.IDENTIFIER || aliasTok.type === TokenType.QUOTED_IDENTIFIER)) {
|
||||||
|
alias = aliasTok.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (first.type === TokenType.IDENTIFIER || first.type === TokenType.QUOTED_IDENTIFIER) {
|
||||||
|
alias = first.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { branches, elseText, alias, source: text };
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 求值
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 对一行求值 CASE 表达式。
|
||||||
|
*
|
||||||
|
* 求值顺序即声明顺序:第一个条件为 TRUE 的分支胜出;
|
||||||
|
* 无分支命中时取 ELSE(未写 ELSE 则为 NULL)。
|
||||||
|
*
|
||||||
|
* 条件用 `matchWhere`(三值逻辑)判定:UNKNOWN **不**算命中
|
||||||
|
* (与 WHERE 只保留 TRUE 的语义一致)。
|
||||||
|
*/
|
||||||
|
function evaluateCase(expr, row) {
|
||||||
|
for (const branch of expr.branches) {
|
||||||
|
if (matchWhere(row, branch.condition, { $col: true })) {
|
||||||
|
return evaluateExpressionValue(branch.resultText, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expr.elseText !== null ? evaluateExpressionValue(expr.elseText, row) : null;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 校验 CASE 条件里引用的列在行源中存在(v0.8.0 / B-4)。
|
||||||
|
*
|
||||||
|
* 为什么必须有:条件用 `matchWhere` 求值时,**引用不存在的列**只会得到
|
||||||
|
* UNKNOWN(三值逻辑的正确行为 —— 引擎层拿不到 schema),于是该分支永不命中,
|
||||||
|
* 整列静默变成 ELSE 值。实测修复前:
|
||||||
|
* `CASE WHEN nope > 1 THEN 'x' ELSE 'y' END` → 每行都是 'y',无任何报错;
|
||||||
|
* 而同一个 `nope` 写在 WHERE 里会正常抛 COLUMN_NOT_FOUND。
|
||||||
|
* 同一语义两套行为,且失败方向是"静默错值"。
|
||||||
|
*
|
||||||
|
* 与 `assertWhereColumnsExist` 的关系:那条路径校验的是 SQL 的 WHERE 子句,
|
||||||
|
* 它拿得到表名与别名;CASE 出现在 SELECT/GROUP BY/HAVING 里,调用点更分散,
|
||||||
|
* 因此这里做**独立的、可复用的**校验,由调用方在有 schema 时调用。
|
||||||
|
*
|
||||||
|
* @param available 该作用域内可见的列名集合(含 `alias.col` 形态;JOIN 时是两表并集)
|
||||||
|
*/
|
||||||
|
function assertCaseColumnsExist(expr, available, context) {
|
||||||
|
const missing = [];
|
||||||
|
const checkRef = (ref) => {
|
||||||
|
const text = ref.trim();
|
||||||
|
if (!text)
|
||||||
|
return;
|
||||||
|
if (available.has(text))
|
||||||
|
return;
|
||||||
|
const bare = text.includes('.') ? text.split('.').pop() : text;
|
||||||
|
if (available.has(bare))
|
||||||
|
return;
|
||||||
|
missing.push(text);
|
||||||
|
};
|
||||||
|
// 条件里的列引用:键位(`n > 1`)与 `$col` 值位(`a = b`)
|
||||||
|
const walkCondition = (cond) => {
|
||||||
|
for (const [key, value] of Object.entries(cond)) {
|
||||||
|
if (key === '$and' || key === '$or') {
|
||||||
|
for (const sub of (Array.isArray(value) ? value : [value]))
|
||||||
|
walkCondition(sub);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === '$not') {
|
||||||
|
walkCondition(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === '$exists' || /^\s*CASE\b/i.test(key))
|
||||||
|
continue;
|
||||||
|
checkRef(key);
|
||||||
|
if (value !== null && typeof value === 'object') {
|
||||||
|
for (const operand of Object.values(value)) {
|
||||||
|
if (operand !== null && typeof operand === 'object' && !Array.isArray(operand) && '$col' in operand) {
|
||||||
|
checkRef(String(operand.$col));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const branch of expr.branches)
|
||||||
|
walkCondition(branch.condition);
|
||||||
|
// 结果片段里的列引用(嵌套 CASE 递归)
|
||||||
|
const checkResult = (text) => {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (/^'/.test(trimmed) || /^-?\d/.test(trimmed) || /^(NULL|TRUE|FALSE)$/i.test(trimmed))
|
||||||
|
return;
|
||||||
|
const nested = parseCaseExpression(trimmed);
|
||||||
|
if (nested) {
|
||||||
|
for (const b of nested.branches)
|
||||||
|
walkCondition(b.condition);
|
||||||
|
for (const b of nested.branches)
|
||||||
|
checkResult(b.resultText);
|
||||||
|
if (nested.elseText)
|
||||||
|
checkResult(nested.elseText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(trimmed))
|
||||||
|
checkRef(trimmed);
|
||||||
|
};
|
||||||
|
for (const branch of expr.branches)
|
||||||
|
checkResult(branch.resultText);
|
||||||
|
if (expr.elseText)
|
||||||
|
checkResult(expr.elseText);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new DatabaseError(`Unknown column${missing.length > 1 ? 's' : ''} ${missing.map((c) => `"${c}"`).join(', ')}`
|
||||||
|
+ ` in ${context}`, 'COLUMN_NOT_FOUND', { columns: missing });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 求值一个"值表达式"片段(THEN/ELSE 的操作数)。
|
||||||
|
*
|
||||||
|
* 支持的形态(覆盖既有全部用例,不引入静默降级):
|
||||||
|
* - 字符串字面量(含 `''` 转义)、数字、TRUE/FALSE/NULL;
|
||||||
|
* - 列引用(裸列名或 `表.列`);
|
||||||
|
* - **嵌套 CASE**(递归求值);
|
||||||
|
* - 其它无法识别的文本 → 抛 `NOT_SUPPORTED`,而不是"当字符串返回"
|
||||||
|
* (原实现把无法识别的文本原样返回,于是嵌套 CASE 的残片
|
||||||
|
* `"big' END ELSE 'small"` 变成了用户可见的返回值)。
|
||||||
|
*/
|
||||||
|
function evaluateExpressionValue(text, row) {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed === '') {
|
||||||
|
throw new DatabaseError('Empty expression value', 'PARSE_ERROR');
|
||||||
|
}
|
||||||
|
// 字符串字面量(SQL 标准 '' 转义)
|
||||||
|
const strLit = trimmed.match(/^'(.*)'$/s);
|
||||||
|
if (strLit)
|
||||||
|
return strLit[1].replace(/''/g, "'");
|
||||||
|
// NULL / 布尔
|
||||||
|
if (/^NULL$/i.test(trimmed))
|
||||||
|
return null;
|
||||||
|
if (/^TRUE$/i.test(trimmed))
|
||||||
|
return true;
|
||||||
|
if (/^FALSE$/i.test(trimmed))
|
||||||
|
return false;
|
||||||
|
// 数字常量(含负号与小数)
|
||||||
|
if (/^-?\d+(\.\d+)?$/.test(trimmed))
|
||||||
|
return Number(trimmed);
|
||||||
|
// 嵌套 CASE
|
||||||
|
const nested = parseCaseExpression(trimmed);
|
||||||
|
if (nested)
|
||||||
|
return evaluateCase(nested, row);
|
||||||
|
// 列引用(含 `表.列`)—— 未知列抛 COLUMN_NOT_FOUND(与投影路径同口径)
|
||||||
|
if (/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(trimmed)) {
|
||||||
|
const value = resolveColumnValue(row, trimmed, { strict: false, context: 'CASE result' });
|
||||||
|
if (isUnresolved(value)) {
|
||||||
|
throw new DatabaseError(`Unknown column "${trimmed}" in expression`, 'COLUMN_NOT_FOUND', {
|
||||||
|
column: trimmed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
throw new DatabaseError(`Unsupported expression in CASE result: "${trimmed}"`, 'NOT_SUPPORTED', { expression: trimmed });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* metona-sqlark Query Executor — AST 执行器
|
||||||
|
* @module query/executor
|
||||||
|
*
|
||||||
|
* JOIN / GROUP BY / DISTINCT 逻辑在此层处理。
|
||||||
|
*/
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 分组 / 去重键编码(v0.7.4)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 分组 / 去重键
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// v0.8.0(B-2):`encodeValueKey` 已删除 —— 它曾是**第四份**值编码实现
|
||||||
|
// (`sql-compare.ts` 的 `encodeValueKey`、Aria 索引键、COUNT(DISTINCT) 各有其一)。
|
||||||
|
// 四份编码对 null/undefined 的处理各不相同,于是"同两个值在 GROUP BY 相等、
|
||||||
|
// 在 DISTINCT 不等"这类跨路径矛盾无法根除。现在统一使用 `encodeValueKey`:
|
||||||
|
// 类型前缀 + 长度前缀,null 与 undefined 同为 SQL NULL(合并),
|
||||||
|
// 且编码不会与数据内容冲突。
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CASE WHEN 表达式
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// v0.8.0(B-4):解析与求值已迁移到 `query/expression.ts`。
|
||||||
|
//
|
||||||
|
// 为什么必须搬走:原实现用**正则**在 SQL 文本上切分 WHEN/THEN/ELSE,不认字符串
|
||||||
|
// 字面量与嵌套结构,实测出静默错值:
|
||||||
|
// `CASE WHEN n>10 THEN CASE WHEN n>25 THEN 'huge' ELSE 'big' END ELSE 'small' END`
|
||||||
|
// → 返回字符串 "big' END ELSE 'small"(正则把嵌套 CASE 的 ELSE 当成自己的分支
|
||||||
|
// 边界,残片被原样返回给用户);条件解析失败时还 `cond = null` 静默跳过该
|
||||||
|
// 分支 → 整列变成 ELSE 值而不报错。
|
||||||
|
// 新实现复用 `sql/lexer` 的 token 流(带 position)做递归下降,天然支持嵌套与
|
||||||
|
// 字面量内的关键字,并把条件交给与 WHERE **完全相同**的解析器。
|
||||||
/**
|
/**
|
||||||
* 解析聚合函数表达式 —— **唯一**的聚合识别实现。
|
* 解析聚合函数表达式 —— **唯一**的聚合识别实现。
|
||||||
*
|
*
|
||||||
@@ -13121,6 +13557,23 @@ function bareReference(reference) {
|
|||||||
return text;
|
return text;
|
||||||
return text.split('.').pop();
|
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) {
|
function resolveAliasSource(source, row) {
|
||||||
const text = source.trim();
|
const text = source.trim();
|
||||||
// 字符串常量(含 SQL 标准 '' 转义还原)
|
// 字符串常量(含 SQL 标准 '' 转义还原)
|
||||||
@@ -13173,61 +13626,6 @@ function reduceNumeric(values, op) {
|
|||||||
}
|
}
|
||||||
return op === 'AVG' ? acc / values.length : acc;
|
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
|
// Executor
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -13548,6 +13946,13 @@ class QueryExecutor {
|
|||||||
const shape = this.analyzeSelect(stmt);
|
const shape = this.analyzeSelect(stmt);
|
||||||
const { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe } = shape;
|
const { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe } = shape;
|
||||||
let rows;
|
let rows;
|
||||||
|
// v0.8.0(B-4):CASE 表达式里的列引用必须在**分组/聚合之前**校验。
|
||||||
|
//
|
||||||
|
// 时机很关键:分组会把行替换为"分组键 + 聚合值",此后源列已不存在,
|
||||||
|
// 任何基于行的校验都会误报未知列(实测:`GROUP BY CASE ... END` 在投影期
|
||||||
|
// 校验会报 Unknown column "n")。这里按**schema** 建可见列集合,
|
||||||
|
// 因此与行形状无关,天然正确。
|
||||||
|
await this.assertCaseColumnsExist(stmt, isJoinQuery);
|
||||||
// v0.8.0(A37):WHERE 里的列引用也必须校验存在性。
|
// v0.8.0(A37):WHERE 里的列引用也必须校验存在性。
|
||||||
//
|
//
|
||||||
// 投影侧的同类校验(assertProjectionColumnsExist)早已存在,但 WHERE 侧一直
|
// 投影侧的同类校验(assertProjectionColumnsExist)早已存在,但 WHERE 侧一直
|
||||||
@@ -13982,8 +14387,13 @@ class QueryExecutor {
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
// v0.7.4: 类型安全键编码 —— 此前 String(row[col] ?? 'null') 使
|
// v0.7.4: 类型安全键编码 —— 此前 String(row[col] ?? 'null') 使
|
||||||
// null 与字符串 'null' 合并为一组(GROUP BY 静默少组)
|
// null 与字符串 'null' 合并为一组(GROUP BY 静默少组)
|
||||||
|
//
|
||||||
|
// v0.8.0(B-4):分组项可以是**表达式**(CASE),此时按行求值取分组键 ——
|
||||||
|
// 此前一律当列名去查,于是 `GROUP BY CASE ... END` 报
|
||||||
|
// `COLUMN_NOT_FOUND Unknown column "CASE WHEN ..."`(实测),
|
||||||
|
// 而"按条件分组"是 SQL 里最常见的分析写法之一。
|
||||||
const key = groupColumns
|
const key = groupColumns
|
||||||
.map((col) => encodeValueKey(resolveColumnValue(row, col, { strict: true, context: 'GROUP BY' })))
|
.map((col) => encodeValueKey(resolveGroupKeyValue(row, col)))
|
||||||
.join('\x1f');
|
.join('\x1f');
|
||||||
if (!groups.has(key))
|
if (!groups.has(key))
|
||||||
groups.set(key, []);
|
groups.set(key, []);
|
||||||
@@ -14000,7 +14410,16 @@ class QueryExecutor {
|
|||||||
// 分组列以基列名输出(`GROUP BY grp` 引用别名时输出基列 `g`,
|
// 分组列以基列名输出(`GROUP BY grp` 引用别名时输出基列 `g`,
|
||||||
// 与 SELECT 列表里 `g AS grp` 的投影可正确对应)
|
// 与 SELECT 列表里 `g AS grp` 的投影可正确对应)
|
||||||
for (const col of groupColumns) {
|
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 与输出键写入
|
// 先算聚合(含仅 HAVING 引用的),统一以 exprKey 与输出键写入
|
||||||
for (const agg of aggregateExprs) {
|
for (const agg of aggregateExprs) {
|
||||||
@@ -14088,7 +14507,7 @@ class QueryExecutor {
|
|||||||
output[agg.outputKey] = aggregated[agg.outputKey];
|
output[agg.outputKey] = aggregated[agg.outputKey];
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const caseExpr = /^\s*CASE\b/i.test(colExpr) ? parseCaseExpression(colExpr) : null;
|
const caseExpr = parseCaseExpression(colExpr);
|
||||||
if (caseExpr) {
|
if (caseExpr) {
|
||||||
const key = caseExpr.alias ?? colExpr;
|
const key = caseExpr.alias ?? colExpr;
|
||||||
output[key] = aggregated[key];
|
output[key] = aggregated[key];
|
||||||
@@ -14114,6 +14533,13 @@ class QueryExecutor {
|
|||||||
resolveGroupByColumns(stmt) {
|
resolveGroupByColumns(stmt) {
|
||||||
const aliases = new Map();
|
const aliases = new Map();
|
||||||
for (const col of stmt.columns) {
|
for (const col of stmt.columns) {
|
||||||
|
// v0.8.0(B-4):CASE 的 `AS alias` 也要进别名表 ——
|
||||||
|
// `SELECT CASE ... END AS band ... GROUP BY band` 是最常见的条件分组写法。
|
||||||
|
const caseExpr = parseCaseExpression(col);
|
||||||
|
if (caseExpr?.alias) {
|
||||||
|
aliases.set(caseExpr.alias, col.trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const m = col.match(/^(.+?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
|
const m = col.match(/^(.+?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
|
||||||
if (m)
|
if (m)
|
||||||
aliases.set(m[2], m[1].trim());
|
aliases.set(m[2], m[1].trim());
|
||||||
@@ -14157,7 +14583,7 @@ class QueryExecutor {
|
|||||||
*/
|
*/
|
||||||
computeAggregate(func, rows, arg) {
|
computeAggregate(func, rows, arg) {
|
||||||
// v0.3.2: 聚合参数支持 CASE WHEN 表达式(如 SUM(CASE WHEN age > 18 THEN 1 ELSE 0 END))
|
// 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 剥离后传入
|
// v0.4.0: COUNT(DISTINCT col) —— distinct 由 parseAggregateExpression 剥离后传入
|
||||||
const argCol = arg.trim();
|
const argCol = arg.trim();
|
||||||
// v0.8.0(A25):列引用统一走 resolveColumnValue(别名前缀剥离 + 后缀回退)。
|
// v0.8.0(A25):列引用统一走 resolveColumnValue(别名前缀剥离 + 后缀回退)。
|
||||||
@@ -14546,7 +14972,7 @@ class QueryExecutor {
|
|||||||
names.add(agg.outputKey);
|
names.add(agg.outputKey);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const caseExpr = /^\s*CASE\b/i.test(col) ? parseCaseExpression(col) : null;
|
const caseExpr = parseCaseExpression(col);
|
||||||
if (caseExpr) {
|
if (caseExpr) {
|
||||||
names.add(caseExpr.alias ?? col);
|
names.add(caseExpr.alias ?? col);
|
||||||
continue;
|
continue;
|
||||||
@@ -14644,6 +15070,48 @@ class QueryExecutor {
|
|||||||
+ ` in JOIN ON of table "${stmt.from}"`, 'COLUMN_NOT_FOUND', { columns: missing, from: stmt.from });
|
+ ` in JOIN ON of table "${stmt.from}"`, 'COLUMN_NOT_FOUND', { columns: missing, from: stmt.from });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-4):校验 SELECT / GROUP BY / HAVING / ORDER BY 里 CASE 表达式引用的列存在。
|
||||||
|
*
|
||||||
|
* 为什么单独一个方法:CASE 可以出现在四个子句里,而每个子句的校验时机不同
|
||||||
|
* (WHERE 有 `assertWhereColumnsExist`,投影有 `assertProjectionColumnsExist`)。
|
||||||
|
* 统一在这里按 **schema** 收集可见列,与行形状解耦,避免"分组后校验不到源列"。
|
||||||
|
*/
|
||||||
|
async assertCaseColumnsExist(stmt, isJoinQuery) {
|
||||||
|
if (stmt.fromSubquery)
|
||||||
|
return; // 派生表列来自子查询投影,需另行解析
|
||||||
|
if (!stmt.from)
|
||||||
|
return;
|
||||||
|
const available = new Set();
|
||||||
|
const addTable = async (table, alias) => {
|
||||||
|
const schema = await this.engine.getTableSchema(table);
|
||||||
|
if (!schema)
|
||||||
|
return;
|
||||||
|
for (const col of Object.keys(schema.columns)) {
|
||||||
|
available.add(col);
|
||||||
|
available.add(`${alias}.${col}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await addTable(stmt.from, stmt.alias ?? stmt.from);
|
||||||
|
if (isJoinQuery) {
|
||||||
|
for (const join of stmt.joins ?? [])
|
||||||
|
await addTable(join.table, join.alias ?? join.table);
|
||||||
|
}
|
||||||
|
const check = (text, context) => {
|
||||||
|
const caseExpr = parseCaseExpression(text);
|
||||||
|
if (caseExpr)
|
||||||
|
assertCaseColumnsExist(caseExpr, available, context);
|
||||||
|
};
|
||||||
|
for (const col of stmt.columns)
|
||||||
|
check(col, `CASE expression "${col.trim()}"`);
|
||||||
|
for (const groupCol of stmt.groupBy ?? [])
|
||||||
|
check(groupCol, `GROUP BY "${groupCol}"`);
|
||||||
|
for (const order of stmt.orderBy ?? [])
|
||||||
|
check(order.column, `ORDER BY "${order.column}"`);
|
||||||
|
// HAVING 的 CASE 出现在键位(`HAVING CASE ... END = 1`)
|
||||||
|
for (const key of Object.keys(stmt.having ?? {}))
|
||||||
|
check(key, `HAVING "${key}"`);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* v0.8.0(A37):校验 WHERE 中出现的列名都存在于行源。
|
* v0.8.0(A37):校验 WHERE 中出现的列名都存在于行源。
|
||||||
*
|
*
|
||||||
@@ -14847,6 +15315,9 @@ class QueryExecutor {
|
|||||||
const col = raw.trim();
|
const col = raw.trim();
|
||||||
if (col === '*')
|
if (col === '*')
|
||||||
continue;
|
continue;
|
||||||
|
// CASE 表达式在此跳过:它内部的列引用由 assertCaseColumnsExist 在
|
||||||
|
// **分组/聚合之前**(仍在源行形状上)单独校验 —— 此处 rows 可能已被
|
||||||
|
// 分组替换(只剩分组键与聚合值),拿不到源列,会误报未知列。
|
||||||
if (parseCaseExpression(col))
|
if (parseCaseExpression(col))
|
||||||
continue;
|
continue;
|
||||||
let reference = col;
|
let reference = col;
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+8
@@ -776,6 +776,14 @@ declare class QueryExecutor {
|
|||||||
* 裸列名只要求"某一侧存在"(`ON k = k` 的既有语义是取主表列)。
|
* 裸列名只要求"某一侧存在"(`ON k = k` 的既有语义是取主表列)。
|
||||||
*/
|
*/
|
||||||
private validateJoinOnColumns;
|
private validateJoinOnColumns;
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-4):校验 SELECT / GROUP BY / HAVING / ORDER BY 里 CASE 表达式引用的列存在。
|
||||||
|
*
|
||||||
|
* 为什么单独一个方法:CASE 可以出现在四个子句里,而每个子句的校验时机不同
|
||||||
|
* (WHERE 有 `assertWhereColumnsExist`,投影有 `assertProjectionColumnsExist`)。
|
||||||
|
* 统一在这里按 **schema** 收集可见列,与行形状解耦,避免"分组后校验不到源列"。
|
||||||
|
*/
|
||||||
|
private assertCaseColumnsExist;
|
||||||
/**
|
/**
|
||||||
* v0.8.0(A37):校验 WHERE 中出现的列名都存在于行源。
|
* v0.8.0(A37):校验 WHERE 中出现的列名都存在于行源。
|
||||||
*
|
*
|
||||||
|
|||||||
Vendored
+589
-118
@@ -11311,58 +11311,69 @@ class Lexer {
|
|||||||
/** 读取下一个 Token */
|
/** 读取下一个 Token */
|
||||||
nextToken() {
|
nextToken() {
|
||||||
this.skipWhitespaceAndComments();
|
this.skipWhitespaceAndComments();
|
||||||
|
/**
|
||||||
|
* v0.8.0:token 的**起始位置**在读到任何字符之前记录。
|
||||||
|
*
|
||||||
|
* 为什么必须在这里记:此前的 position 由各 reader 自行回推,语义**按 token
|
||||||
|
* 类型不一致** —— `readString` 用 `start = this.position + 1`(指向引号**之内**),
|
||||||
|
* 而 `readIdentifier`/`readNumber` 用 `this.position - len`(指向首字符)。
|
||||||
|
* 于是 `'big'` 报 position 23 而实际从 24 开始,任何"按 position 切片"的
|
||||||
|
* 调用方都会多切一个字符(实测:CASE 的 THEN 值被切成 `"big'"`)。
|
||||||
|
* 这里统一为"token 首字符在源码中的下标",所有类型一致。
|
||||||
|
*/
|
||||||
|
const tokenStart = this.position;
|
||||||
let tok;
|
let tok;
|
||||||
switch (this.ch) {
|
switch (this.ch) {
|
||||||
case ',':
|
case ',':
|
||||||
tok = this.makeToken(TokenType.COMMA, ',');
|
tok = this.makeToken(TokenType.COMMA, ',', tokenStart);
|
||||||
break;
|
break;
|
||||||
case '(':
|
case '(':
|
||||||
tok = this.makeToken(TokenType.LPAREN, '(');
|
tok = this.makeToken(TokenType.LPAREN, '(', tokenStart);
|
||||||
break;
|
break;
|
||||||
case ')':
|
case ')':
|
||||||
tok = this.makeToken(TokenType.RPAREN, ')');
|
tok = this.makeToken(TokenType.RPAREN, ')', tokenStart);
|
||||||
break;
|
break;
|
||||||
case ';':
|
case ';':
|
||||||
tok = this.makeToken(TokenType.SEMICOLON, ';');
|
tok = this.makeToken(TokenType.SEMICOLON, ';');
|
||||||
break;
|
break;
|
||||||
case '*':
|
case '*':
|
||||||
tok = this.makeToken(TokenType.STAR, '*');
|
tok = this.makeToken(TokenType.STAR, '*', tokenStart);
|
||||||
break;
|
break;
|
||||||
case '.':
|
case '.':
|
||||||
tok = this.makeToken(TokenType.DOT, '.');
|
tok = this.makeToken(TokenType.DOT, '.', tokenStart);
|
||||||
break;
|
break;
|
||||||
case '=':
|
case '=':
|
||||||
tok = this.makeToken(TokenType.EQ, '=');
|
tok = this.makeToken(TokenType.EQ, '=', tokenStart);
|
||||||
break;
|
break;
|
||||||
case '!':
|
case '!':
|
||||||
if (this.peekChar() === '=') {
|
if (this.peekChar() === '=') {
|
||||||
this.readChar();
|
this.readChar();
|
||||||
tok = this.makeToken(TokenType.NEQ, '!=');
|
tok = this.makeToken(TokenType.NEQ, '!=', tokenStart);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
tok = this.makeToken(TokenType.ILLEGAL, '!');
|
tok = this.makeToken(TokenType.ILLEGAL, '!', tokenStart);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case '>':
|
case '>':
|
||||||
if (this.peekChar() === '=') {
|
if (this.peekChar() === '=') {
|
||||||
this.readChar();
|
this.readChar();
|
||||||
tok = this.makeToken(TokenType.GTE, '>=');
|
tok = this.makeToken(TokenType.GTE, '>=', tokenStart);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
tok = this.makeToken(TokenType.GT, '>');
|
tok = this.makeToken(TokenType.GT, '>', tokenStart);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case '<':
|
case '<':
|
||||||
if (this.peekChar() === '=') {
|
if (this.peekChar() === '=') {
|
||||||
this.readChar();
|
this.readChar();
|
||||||
tok = this.makeToken(TokenType.LTE, '<=');
|
tok = this.makeToken(TokenType.LTE, '<=', tokenStart);
|
||||||
}
|
}
|
||||||
else if (this.peekChar() === '>') {
|
else if (this.peekChar() === '>') {
|
||||||
this.readChar();
|
this.readChar();
|
||||||
tok = this.makeToken(TokenType.NEQ, '<>');
|
tok = this.makeToken(TokenType.NEQ, '<>', tokenStart);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
tok = this.makeToken(TokenType.LT, '<');
|
tok = this.makeToken(TokenType.LT, '<', tokenStart);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "'":
|
case "'":
|
||||||
@@ -11379,24 +11390,17 @@ class Lexer {
|
|||||||
if (this.isLetter(this.ch)) {
|
if (this.isLetter(this.ch)) {
|
||||||
const ident = this.readIdentifier();
|
const ident = this.readIdentifier();
|
||||||
const keyword = KEYWORDS[ident.toUpperCase()];
|
const keyword = KEYWORDS[ident.toUpperCase()];
|
||||||
tok = {
|
// v0.8.0: 用 tokenStart(首字符下标),不再用 position - len 回推
|
||||||
type: keyword ?? TokenType.IDENTIFIER,
|
tok = { type: keyword ?? TokenType.IDENTIFIER, value: ident, position: tokenStart };
|
||||||
value: ident,
|
|
||||||
position: this.position - ident.length,
|
|
||||||
};
|
|
||||||
return tok; // 已读取完毕,不需要再 readChar
|
return tok; // 已读取完毕,不需要再 readChar
|
||||||
}
|
}
|
||||||
else if (this.isDigit(this.ch) || (this.ch === '-' && this.isDigit(this.peekChar()))) {
|
else if (this.isDigit(this.ch) || (this.ch === '-' && this.isDigit(this.peekChar()))) {
|
||||||
const num = this.readNumber();
|
const num = this.readNumber();
|
||||||
tok = {
|
tok = { type: TokenType.NUMBER, value: num, position: tokenStart };
|
||||||
type: TokenType.NUMBER,
|
|
||||||
value: num,
|
|
||||||
position: this.position - num.length,
|
|
||||||
};
|
|
||||||
return tok;
|
return tok;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
tok = this.makeToken(TokenType.ILLEGAL, this.ch);
|
tok = this.makeToken(TokenType.ILLEGAL, this.ch, tokenStart);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -11514,7 +11518,11 @@ class Lexer {
|
|||||||
* 完全一致,且任意以反斜杠结尾的参数值都能正确绑定。
|
* 完全一致,且任意以反斜杠结尾的参数值都能正确绑定。
|
||||||
*/
|
*/
|
||||||
readString() {
|
readString() {
|
||||||
const start = this.position + 1; // 跳过一个引号
|
// v0.8.0: start = **开引号**的位置(此前 +1 指向引号之内,导致所有按
|
||||||
|
// position 切片的调用方都多切一个字符)。未闭合错误消息里用的 start 仍取
|
||||||
|
// 引号之后的位置,便于用户定位到内容起点。
|
||||||
|
const start = this.position;
|
||||||
|
const contentStart = this.position + 1;
|
||||||
this.readChar(); // 跳过开始引号
|
this.readChar(); // 跳过开始引号
|
||||||
let value = '';
|
let value = '';
|
||||||
while (this.ch !== '') {
|
while (this.ch !== '') {
|
||||||
@@ -11534,7 +11542,7 @@ class Lexer {
|
|||||||
// v0.7.2: 未闭合字符串字面量显式报错(此前静默返回残缺 STRING token,
|
// v0.7.2: 未闭合字符串字面量显式报错(此前静默返回残缺 STRING token,
|
||||||
// 上层可解析出错误结果,如 `SELECT 'abc` 被当作合法常量列)
|
// 上层可解析出错误结果,如 `SELECT 'abc` 被当作合法常量列)
|
||||||
if (this.ch === '') {
|
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 {
|
return {
|
||||||
type: TokenType.STRING,
|
type: TokenType.STRING,
|
||||||
@@ -11549,8 +11557,8 @@ class Lexer {
|
|||||||
* 未闭合同样显式报错,与字符串字面量保持一致。
|
* 未闭合同样显式报错,与字符串字面量保持一致。
|
||||||
*/
|
*/
|
||||||
readQuotedIdentifier() {
|
readQuotedIdentifier() {
|
||||||
|
// v0.8.0: start = 开引号位置(position 语义统一为 token 首字符)
|
||||||
const start = this.position;
|
const start = this.position;
|
||||||
const contentStart = this.position + 1;
|
|
||||||
this.readChar(); // 跳过开始引号
|
this.readChar(); // 跳过开始引号
|
||||||
let value = '';
|
let value = '';
|
||||||
while (this.ch !== '') {
|
while (this.ch !== '') {
|
||||||
@@ -11575,7 +11583,9 @@ class Lexer {
|
|||||||
return {
|
return {
|
||||||
type: TokenType.QUOTED_IDENTIFIER,
|
type: TokenType.QUOTED_IDENTIFIER,
|
||||||
value,
|
value,
|
||||||
position: contentStart,
|
// v0.8.0: 与 STRING 一致 —— position = **开引号**的位置(按 position 切片
|
||||||
|
// 才能取到完整的分隔标识符文本)
|
||||||
|
position: start,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
isLetter(ch) {
|
isLetter(ch) {
|
||||||
@@ -11584,8 +11594,8 @@ class Lexer {
|
|||||||
isDigit(ch) {
|
isDigit(ch) {
|
||||||
return /[0-9]/.test(ch);
|
return /[0-9]/.test(ch);
|
||||||
}
|
}
|
||||||
makeToken(type, value) {
|
makeToken(type, value, start = this.position) {
|
||||||
return { type, value, position: this.position };
|
return { type, value, position: start };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -12957,43 +12967,46 @@ function parseWhereCondition(sql) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* metona-sqlark Query Executor — AST 执行器
|
* metona-sqlark 列引用取值 —— WHERE / 投影 / 聚合 / 表达式**共用**的唯一实现
|
||||||
* @module query/executor
|
* @module query/column-value
|
||||||
*
|
*
|
||||||
* JOIN / GROUP BY / DISTINCT 逻辑在此层处理。
|
* ============================================================================
|
||||||
|
* 为什么必须只有一个实现(PLAN-v0.7.5.md 根因 1)
|
||||||
|
* ============================================================================
|
||||||
|
* "从一行里按名字取一列"曾是四处各写一份的实现,规则各不相同:
|
||||||
|
*
|
||||||
|
* | 位置 | 别名前缀 | 后缀回退 | 未知列 |
|
||||||
|
* |---|---|---|---|
|
||||||
|
* | `projectColumns`(where-matcher) | 否 | 唯一后缀 | 静默丢键 |
|
||||||
|
* | `resolveAliasSource`(executor) | **否** | 否 | 静默 undefined |
|
||||||
|
* | 聚合参数(executor) | 否 | 否 | **静默计 0**(A25) |
|
||||||
|
* | `matchWhere` 的 `resolveField` | 否 | 唯一后缀(多个则 UNRESOLVED) | UNRESOLVED |
|
||||||
|
*
|
||||||
|
* 于是 `COUNT(t.n)` 返回 0(A25)、`SELECT d.id FROM (...) AS d` 返回空集(A36)、
|
||||||
|
* CASE 的 THEN 分支引用列名时行为随调用点变化(B-4)。
|
||||||
|
*
|
||||||
|
* 现在四处都调用本模块的 `resolveColumnValue`:
|
||||||
|
* - JOIN 行的键是 `alias.col`,单表行的键是 `col` —— 两种形态都要支持;
|
||||||
|
* - 取不到值时**由调用方**决定是抛错还是返回 UNRESOLVED(`strict` 选项),
|
||||||
|
* 因为"WHERE 里的未解析引用"与"表达式里的未解析引用"需要不同的上层处理。
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* v0.8.0: 解析 `expr AS alias` 中的 `expr` 取值来源。
|
* 从行里取一个列引用。
|
||||||
*
|
*
|
||||||
* 此前只处理"字符串常量"与"列引用"两种情况,数字/布尔/NULL 常量会走
|
* 解析顺序(与 ORDER BY 的 `stripAlias` 语义一致,保证同一引用在各子句里等价):
|
||||||
* `row[source]` → undefined,于是 `SELECT 1 AS one FROM t` 返回 `[{}]`
|
* 1. 精确命中(行键与引用完全一致,含 `alias.col` 形态);
|
||||||
* (键在、值为 undefined,JSON 序列化后整个键消失),
|
* 2. 剥离别名前缀(`t.n` → `n`);
|
||||||
* 而这正是 EXISTS 子查询里最常见的写法(`SELECT 1 FROM ...`)。
|
* 3. 唯一后缀匹配(行键是 `t.n` 而引用写作 `n`);
|
||||||
*/
|
* 4. 以上都不中 → 依 `strict` 抛错或返回 `UNRESOLVED`。
|
||||||
/**
|
|
||||||
* 列引用取值(唯一实现)。
|
|
||||||
*
|
*
|
||||||
* v0.8.0(A25):为什么必须有这一个函数。
|
* 多个后缀命中视为**歧义**(JOIN 里两表同名列),`strict` 下抛错 ——
|
||||||
*
|
* 静默取第一个正是"结果取决于表顺序"这类难查问题的来源。
|
||||||
* "从行里取一列"此前散落在 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, reference, opts) {
|
function resolveColumnValue(row, reference, opts) {
|
||||||
const text = reference.trim();
|
const text = reference.trim();
|
||||||
if (text in row)
|
if (text in row)
|
||||||
return row[text];
|
return row[text];
|
||||||
// 别名前缀(`t.n` → `n`):JOIN 行用 `alias.col` 作键,但单表路径的
|
// 别名前缀(`t.n` → `n`):JOIN 行用 `alias.col` 作键,单表路径的键不带前缀
|
||||||
// 行键不带前缀,两种形态都必须支持。
|
|
||||||
if (text.includes('.')) {
|
if (text.includes('.')) {
|
||||||
const bare = text.split('.').pop();
|
const bare = text.split('.').pop();
|
||||||
if (bare in row)
|
if (bare in row)
|
||||||
@@ -13015,8 +13028,431 @@ function resolveColumnValue(row, reference, opts) {
|
|||||||
? `Ambiguous column "${text}" in ${opts.context}: present in multiple tables`
|
? `Ambiguous column "${text}" in ${opts.context}: present in multiple tables`
|
||||||
: `Unknown column "${text}" in ${opts.context}`, 'COLUMN_NOT_FOUND', { column: text });
|
: `Unknown column "${text}" in ${opts.context}`, 'COLUMN_NOT_FOUND', { column: text });
|
||||||
}
|
}
|
||||||
return undefined;
|
return UNRESOLVED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* metona-sqlark 表达式解析与求值 —— CASE WHEN 的结构化实现(v0.8.0 / B-4)
|
||||||
|
* @module query/expression
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* 为什么必须换掉原实现(PLAN-v0.7.5.md 根因 3:字符串化 AST 表达式列)
|
||||||
|
* ============================================================================
|
||||||
|
* 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* /WHEN\s+([\s\S]*?)\s+THEN\s+([\s\S]*?)(?=\s+WHEN\s+|\s+ELSE\s+|\s*$)/gi
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 它不认字符串字面量、不认嵌套结构,于是实测出三类错误结果:
|
||||||
|
*
|
||||||
|
* | 输入 | 实测(修复前) | 应有 |
|
||||||
|
* |---|---|---|
|
||||||
|
* | `CASE WHEN n>10 THEN CASE WHEN n>25 THEN 'huge' ELSE 'big' END ELSE 'small' END` | `"big' END ELSE 'small"` / `null` | `huge`/`big`/`small` |
|
||||||
|
* | `CASE WHEN s='WHEN' THEN 'hit' ELSE 'miss' END` | 依赖切分点,可能错 | `miss/hit/miss` |
|
||||||
|
*
|
||||||
|
* 更严重的是**静默错值**:条件解析失败(如引用不存在的列)时 `cond = null`,
|
||||||
|
* 求值直接跳过该分支 —— 整列变成 ELSE 值,没有任何提示;而同一列名出现在
|
||||||
|
* WHERE 里会正常报错。同一语义两套行为。
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* 本模块的做法
|
||||||
|
* ============================================================================
|
||||||
|
* 1. **复用 `sql/lexer` 的 token 流**(带 `position`),不另写一套词法规则 ——
|
||||||
|
* 字符串里的 `WHEN`/`ELSE`、转义引号、注释都由它正确处理,
|
||||||
|
* 因此"切分点"不再可能落在字面量内部。
|
||||||
|
* 2. **递归下降**解析 CASE(天然支持嵌套),并把每个片段按**源码位置切片**,
|
||||||
|
* 交给既有的 `parseWhereCondition` / 字面量解析器处理 ——
|
||||||
|
* 条件语义与 WHERE 完全同源,不再各写一份。
|
||||||
|
* 3. **解析失败即报错**(`PARSE_ERROR` / `COLUMN_NOT_FOUND`),不静默降级为
|
||||||
|
* 某一个分支的值。
|
||||||
|
* 4. 解析结果**缓存**:`parseCaseExpression` 是纯函数,可安全记忆化;
|
||||||
|
* 聚合与逐行投影会对同一表达式反复求值(N 行 × M 次),缓存把解析开销
|
||||||
|
* 从 O(行数) 降到 O(1)。
|
||||||
|
*
|
||||||
|
* 注意:本模块只负责 **CASE** 表达式。普通列引用/字面量/聚合由
|
||||||
|
* `query/executor` 的 `resolveColumnValue` 与 `parseAggregateExpression`
|
||||||
|
* 处理(B-4 的另一半已由 A25 统一)。
|
||||||
|
*/
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 解析
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/** 解析缓存:同一段文本只解析一次(纯函数,可安全记忆化) */
|
||||||
|
const caseParseCache = new Map();
|
||||||
|
/** 缓存上限(防御性:避免长生命周期进程里无界增长) */
|
||||||
|
const CASE_CACHE_LIMIT = 512;
|
||||||
|
/**
|
||||||
|
* 解析 CASE WHEN 表达式文本。
|
||||||
|
*
|
||||||
|
* @param expr 形如 `CASE WHEN a > 1 THEN 'x' ELSE 'y' END AS band` 的片段
|
||||||
|
* @returns 结构化表达式;**不是** CASE 表达式时返回 null(调用方据此走其它分支)
|
||||||
|
* @throws DatabaseError 结构不完整(缺 THEN/END)或条件无法解析
|
||||||
|
*/
|
||||||
|
function parseCaseExpression(expr) {
|
||||||
|
const text = expr.trim();
|
||||||
|
if (!/^\s*CASE\b/i.test(text))
|
||||||
|
return null;
|
||||||
|
const cached = caseParseCache.get(text);
|
||||||
|
if (cached)
|
||||||
|
return cached;
|
||||||
|
const parsed = parseCaseExpressionUncached(text);
|
||||||
|
// 先清理再写入:条目数达到上限时整表清空,避免无界增长。
|
||||||
|
// 用 LRU 会引入额外状态;解析本身是纯函数且调用点集中在少数表达式上,
|
||||||
|
// "清空重建"足够且没有正确性风险。
|
||||||
|
if (caseParseCache.size >= CASE_CACHE_LIMIT)
|
||||||
|
caseParseCache.clear();
|
||||||
|
caseParseCache.set(text, parsed);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 真正的解析实现。
|
||||||
|
*
|
||||||
|
* 用 `tokenize` 得到带位置的 token 流后按源码切片 —— 这样"条件/值片段"与
|
||||||
|
* 原始 SQL 逐字符一致(含引号与转义),可以安全地交给 `parseWhereCondition`。
|
||||||
|
*/
|
||||||
|
function parseCaseExpressionUncached(text) {
|
||||||
|
const tokens = tokenize(text);
|
||||||
|
let i = 0;
|
||||||
|
const fail = (message, position) => {
|
||||||
|
throw new DatabaseError(`${message} (at offset ${position} in "${text}")`, 'PARSE_ERROR');
|
||||||
|
};
|
||||||
|
// CASE
|
||||||
|
if (tokens[i]?.type !== TokenType.CASE)
|
||||||
|
fail('Expected CASE', tokens[i]?.position ?? 0);
|
||||||
|
i++;
|
||||||
|
const branches = [];
|
||||||
|
let elseText = null;
|
||||||
|
/**
|
||||||
|
* 按源码位置取片段。
|
||||||
|
*
|
||||||
|
* 边界必须用"**下一个 token 的起始位置**"作为右开区间:
|
||||||
|
* STRING token 的 `position` 指向**引号之内**(lexer 的 readString 里
|
||||||
|
* `start = position + 1`),用 `position + value.length` 之类算术会把
|
||||||
|
* 结尾引号切掉(实测:`'small'` 被切成 `"small'"`,CASE 全部报
|
||||||
|
* NOT_SUPPORTED —— 这正是本函数必须存在的理由)。
|
||||||
|
* 同理 NUMBER/IDENTIFIER 的 position 也由各自 reader 回推,语义不完全统一;
|
||||||
|
* 只有"下一个 token 的起点"是对所有 token 类型都成立的边界。
|
||||||
|
*/
|
||||||
|
const slice = (startIndex, endIndexExclusive) => {
|
||||||
|
const startTok = tokens[startIndex];
|
||||||
|
if (!startTok)
|
||||||
|
return '';
|
||||||
|
const endTok = tokens[endIndexExclusive];
|
||||||
|
const endPos = endTok ? endTok.position : text.length;
|
||||||
|
return text.slice(startTok.position, endPos).trim();
|
||||||
|
};
|
||||||
|
while (i < tokens.length) {
|
||||||
|
const tok = tokens[i];
|
||||||
|
if (tok.type === TokenType.WHEN) {
|
||||||
|
const condStart = i + 1;
|
||||||
|
// 找与之配对的 THEN:跳过嵌套的括号(CASE 内的子查询/括号表达式)
|
||||||
|
let depth = 0;
|
||||||
|
let thenIndex = -1;
|
||||||
|
for (let j = condStart; j < tokens.length; j++) {
|
||||||
|
const t = tokens[j];
|
||||||
|
if (t.type === TokenType.LPAREN)
|
||||||
|
depth++;
|
||||||
|
else if (t.type === TokenType.RPAREN)
|
||||||
|
depth--;
|
||||||
|
else if (depth === 0 && t.type === TokenType.THEN) {
|
||||||
|
thenIndex = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (depth === 0 && (t.type === TokenType.ELSE || t.type === TokenType.END))
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (thenIndex < 0)
|
||||||
|
fail('CASE WHEN without matching THEN', tok.position);
|
||||||
|
const valueStart = thenIndex + 1;
|
||||||
|
// 值的结束点:下一个同级 WHEN / ELSE / END
|
||||||
|
let depth2 = 0;
|
||||||
|
let valueEnd = tokens.length;
|
||||||
|
for (let j = valueStart; j < tokens.length; j++) {
|
||||||
|
const t = tokens[j];
|
||||||
|
if (t.type === TokenType.LPAREN)
|
||||||
|
depth2++;
|
||||||
|
else if (t.type === TokenType.RPAREN)
|
||||||
|
depth2--;
|
||||||
|
else if (depth2 === 0 && (t.type === TokenType.WHEN || t.type === TokenType.ELSE || t.type === TokenType.END)) {
|
||||||
|
valueEnd = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (depth2 === 0 && t.type === TokenType.CASE) {
|
||||||
|
// 嵌套 CASE 作为一个整体:跳到与它配对的 END
|
||||||
|
let nestedDepth = 1;
|
||||||
|
for (let k = j + 1; k < tokens.length; k++) {
|
||||||
|
if (tokens[k].type === TokenType.CASE)
|
||||||
|
nestedDepth++;
|
||||||
|
else if (tokens[k].type === TokenType.END) {
|
||||||
|
nestedDepth--;
|
||||||
|
if (nestedDepth === 0) {
|
||||||
|
j = k;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const conditionText = slice(condStart, thenIndex);
|
||||||
|
const resultText = slice(valueStart, valueEnd);
|
||||||
|
if (!conditionText)
|
||||||
|
fail('CASE WHEN has an empty condition', tok.position);
|
||||||
|
if (!resultText)
|
||||||
|
fail('CASE THEN has an empty result', tokens[valueStart]?.position ?? tok.position);
|
||||||
|
// 条件在**解析期**交给与 WHERE 完全相同的解析器 —— 语义同源,
|
||||||
|
// 且"引用不存在的列"之类问题按 WHERE 的口径处理(不再静默跳过分支)。
|
||||||
|
let condition;
|
||||||
|
try {
|
||||||
|
condition = parseWhereCondition(conditionText);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
throw new DatabaseError(`Invalid CASE WHEN condition "${conditionText}": ${error.message}`, 'PARSE_ERROR', { condition: conditionText });
|
||||||
|
}
|
||||||
|
branches.push({ conditionText, resultText, condition });
|
||||||
|
i = valueEnd;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tok.type === TokenType.ELSE) {
|
||||||
|
const elseStart = i + 1;
|
||||||
|
let depth = 0;
|
||||||
|
let elseEnd = tokens.length;
|
||||||
|
for (let j = elseStart; j < tokens.length; j++) {
|
||||||
|
const t = tokens[j];
|
||||||
|
if (t.type === TokenType.LPAREN)
|
||||||
|
depth++;
|
||||||
|
else if (t.type === TokenType.RPAREN)
|
||||||
|
depth--;
|
||||||
|
else if (depth === 0 && t.type === TokenType.END) {
|
||||||
|
elseEnd = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (depth === 0 && t.type === TokenType.CASE) {
|
||||||
|
let nestedDepth = 1;
|
||||||
|
for (let k = j + 1; k < tokens.length; k++) {
|
||||||
|
if (tokens[k].type === TokenType.CASE)
|
||||||
|
nestedDepth++;
|
||||||
|
else if (tokens[k].type === TokenType.END) {
|
||||||
|
nestedDepth--;
|
||||||
|
if (nestedDepth === 0) {
|
||||||
|
j = k;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseText = slice(elseStart, elseEnd);
|
||||||
|
if (!elseText)
|
||||||
|
fail('CASE ELSE has an empty result', tok.position);
|
||||||
|
i = elseEnd;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tok.type === TokenType.END) {
|
||||||
|
i++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
fail(`Unexpected token "${tok.value}" in CASE`, tok.position);
|
||||||
|
}
|
||||||
|
if (branches.length === 0) {
|
||||||
|
throw new DatabaseError(`CASE expression has no WHEN branch: "${text}"`, 'PARSE_ERROR');
|
||||||
|
}
|
||||||
|
// 可选别名:END AS alias / END alias
|
||||||
|
let alias = null;
|
||||||
|
const rest = tokens.slice(i).filter((t) => t.type !== TokenType.EOF && t.type !== TokenType.SEMICOLON);
|
||||||
|
if (rest.length > 0) {
|
||||||
|
const first = rest[0];
|
||||||
|
if (first.type === TokenType.AS) {
|
||||||
|
const aliasTok = rest[1];
|
||||||
|
if (aliasTok && (aliasTok.type === TokenType.IDENTIFIER || aliasTok.type === TokenType.QUOTED_IDENTIFIER)) {
|
||||||
|
alias = aliasTok.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (first.type === TokenType.IDENTIFIER || first.type === TokenType.QUOTED_IDENTIFIER) {
|
||||||
|
alias = first.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { branches, elseText, alias, source: text };
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 求值
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 对一行求值 CASE 表达式。
|
||||||
|
*
|
||||||
|
* 求值顺序即声明顺序:第一个条件为 TRUE 的分支胜出;
|
||||||
|
* 无分支命中时取 ELSE(未写 ELSE 则为 NULL)。
|
||||||
|
*
|
||||||
|
* 条件用 `matchWhere`(三值逻辑)判定:UNKNOWN **不**算命中
|
||||||
|
* (与 WHERE 只保留 TRUE 的语义一致)。
|
||||||
|
*/
|
||||||
|
function evaluateCase(expr, row) {
|
||||||
|
for (const branch of expr.branches) {
|
||||||
|
if (matchWhere(row, branch.condition, { $col: true })) {
|
||||||
|
return evaluateExpressionValue(branch.resultText, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expr.elseText !== null ? evaluateExpressionValue(expr.elseText, row) : null;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 校验 CASE 条件里引用的列在行源中存在(v0.8.0 / B-4)。
|
||||||
|
*
|
||||||
|
* 为什么必须有:条件用 `matchWhere` 求值时,**引用不存在的列**只会得到
|
||||||
|
* UNKNOWN(三值逻辑的正确行为 —— 引擎层拿不到 schema),于是该分支永不命中,
|
||||||
|
* 整列静默变成 ELSE 值。实测修复前:
|
||||||
|
* `CASE WHEN nope > 1 THEN 'x' ELSE 'y' END` → 每行都是 'y',无任何报错;
|
||||||
|
* 而同一个 `nope` 写在 WHERE 里会正常抛 COLUMN_NOT_FOUND。
|
||||||
|
* 同一语义两套行为,且失败方向是"静默错值"。
|
||||||
|
*
|
||||||
|
* 与 `assertWhereColumnsExist` 的关系:那条路径校验的是 SQL 的 WHERE 子句,
|
||||||
|
* 它拿得到表名与别名;CASE 出现在 SELECT/GROUP BY/HAVING 里,调用点更分散,
|
||||||
|
* 因此这里做**独立的、可复用的**校验,由调用方在有 schema 时调用。
|
||||||
|
*
|
||||||
|
* @param available 该作用域内可见的列名集合(含 `alias.col` 形态;JOIN 时是两表并集)
|
||||||
|
*/
|
||||||
|
function assertCaseColumnsExist(expr, available, context) {
|
||||||
|
const missing = [];
|
||||||
|
const checkRef = (ref) => {
|
||||||
|
const text = ref.trim();
|
||||||
|
if (!text)
|
||||||
|
return;
|
||||||
|
if (available.has(text))
|
||||||
|
return;
|
||||||
|
const bare = text.includes('.') ? text.split('.').pop() : text;
|
||||||
|
if (available.has(bare))
|
||||||
|
return;
|
||||||
|
missing.push(text);
|
||||||
|
};
|
||||||
|
// 条件里的列引用:键位(`n > 1`)与 `$col` 值位(`a = b`)
|
||||||
|
const walkCondition = (cond) => {
|
||||||
|
for (const [key, value] of Object.entries(cond)) {
|
||||||
|
if (key === '$and' || key === '$or') {
|
||||||
|
for (const sub of (Array.isArray(value) ? value : [value]))
|
||||||
|
walkCondition(sub);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === '$not') {
|
||||||
|
walkCondition(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === '$exists' || /^\s*CASE\b/i.test(key))
|
||||||
|
continue;
|
||||||
|
checkRef(key);
|
||||||
|
if (value !== null && typeof value === 'object') {
|
||||||
|
for (const operand of Object.values(value)) {
|
||||||
|
if (operand !== null && typeof operand === 'object' && !Array.isArray(operand) && '$col' in operand) {
|
||||||
|
checkRef(String(operand.$col));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const branch of expr.branches)
|
||||||
|
walkCondition(branch.condition);
|
||||||
|
// 结果片段里的列引用(嵌套 CASE 递归)
|
||||||
|
const checkResult = (text) => {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (/^'/.test(trimmed) || /^-?\d/.test(trimmed) || /^(NULL|TRUE|FALSE)$/i.test(trimmed))
|
||||||
|
return;
|
||||||
|
const nested = parseCaseExpression(trimmed);
|
||||||
|
if (nested) {
|
||||||
|
for (const b of nested.branches)
|
||||||
|
walkCondition(b.condition);
|
||||||
|
for (const b of nested.branches)
|
||||||
|
checkResult(b.resultText);
|
||||||
|
if (nested.elseText)
|
||||||
|
checkResult(nested.elseText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(trimmed))
|
||||||
|
checkRef(trimmed);
|
||||||
|
};
|
||||||
|
for (const branch of expr.branches)
|
||||||
|
checkResult(branch.resultText);
|
||||||
|
if (expr.elseText)
|
||||||
|
checkResult(expr.elseText);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new DatabaseError(`Unknown column${missing.length > 1 ? 's' : ''} ${missing.map((c) => `"${c}"`).join(', ')}`
|
||||||
|
+ ` in ${context}`, 'COLUMN_NOT_FOUND', { columns: missing });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 求值一个"值表达式"片段(THEN/ELSE 的操作数)。
|
||||||
|
*
|
||||||
|
* 支持的形态(覆盖既有全部用例,不引入静默降级):
|
||||||
|
* - 字符串字面量(含 `''` 转义)、数字、TRUE/FALSE/NULL;
|
||||||
|
* - 列引用(裸列名或 `表.列`);
|
||||||
|
* - **嵌套 CASE**(递归求值);
|
||||||
|
* - 其它无法识别的文本 → 抛 `NOT_SUPPORTED`,而不是"当字符串返回"
|
||||||
|
* (原实现把无法识别的文本原样返回,于是嵌套 CASE 的残片
|
||||||
|
* `"big' END ELSE 'small"` 变成了用户可见的返回值)。
|
||||||
|
*/
|
||||||
|
function evaluateExpressionValue(text, row) {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed === '') {
|
||||||
|
throw new DatabaseError('Empty expression value', 'PARSE_ERROR');
|
||||||
|
}
|
||||||
|
// 字符串字面量(SQL 标准 '' 转义)
|
||||||
|
const strLit = trimmed.match(/^'(.*)'$/s);
|
||||||
|
if (strLit)
|
||||||
|
return strLit[1].replace(/''/g, "'");
|
||||||
|
// NULL / 布尔
|
||||||
|
if (/^NULL$/i.test(trimmed))
|
||||||
|
return null;
|
||||||
|
if (/^TRUE$/i.test(trimmed))
|
||||||
|
return true;
|
||||||
|
if (/^FALSE$/i.test(trimmed))
|
||||||
|
return false;
|
||||||
|
// 数字常量(含负号与小数)
|
||||||
|
if (/^-?\d+(\.\d+)?$/.test(trimmed))
|
||||||
|
return Number(trimmed);
|
||||||
|
// 嵌套 CASE
|
||||||
|
const nested = parseCaseExpression(trimmed);
|
||||||
|
if (nested)
|
||||||
|
return evaluateCase(nested, row);
|
||||||
|
// 列引用(含 `表.列`)—— 未知列抛 COLUMN_NOT_FOUND(与投影路径同口径)
|
||||||
|
if (/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(trimmed)) {
|
||||||
|
const value = resolveColumnValue(row, trimmed, { strict: false, context: 'CASE result' });
|
||||||
|
if (isUnresolved(value)) {
|
||||||
|
throw new DatabaseError(`Unknown column "${trimmed}" in expression`, 'COLUMN_NOT_FOUND', {
|
||||||
|
column: trimmed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
throw new DatabaseError(`Unsupported expression in CASE result: "${trimmed}"`, 'NOT_SUPPORTED', { expression: trimmed });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* metona-sqlark Query Executor — AST 执行器
|
||||||
|
* @module query/executor
|
||||||
|
*
|
||||||
|
* JOIN / GROUP BY / DISTINCT 逻辑在此层处理。
|
||||||
|
*/
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 分组 / 去重键编码(v0.7.4)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 分组 / 去重键
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// v0.8.0(B-2):`encodeValueKey` 已删除 —— 它曾是**第四份**值编码实现
|
||||||
|
// (`sql-compare.ts` 的 `encodeValueKey`、Aria 索引键、COUNT(DISTINCT) 各有其一)。
|
||||||
|
// 四份编码对 null/undefined 的处理各不相同,于是"同两个值在 GROUP BY 相等、
|
||||||
|
// 在 DISTINCT 不等"这类跨路径矛盾无法根除。现在统一使用 `encodeValueKey`:
|
||||||
|
// 类型前缀 + 长度前缀,null 与 undefined 同为 SQL NULL(合并),
|
||||||
|
// 且编码不会与数据内容冲突。
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CASE WHEN 表达式
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// v0.8.0(B-4):解析与求值已迁移到 `query/expression.ts`。
|
||||||
|
//
|
||||||
|
// 为什么必须搬走:原实现用**正则**在 SQL 文本上切分 WHEN/THEN/ELSE,不认字符串
|
||||||
|
// 字面量与嵌套结构,实测出静默错值:
|
||||||
|
// `CASE WHEN n>10 THEN CASE WHEN n>25 THEN 'huge' ELSE 'big' END ELSE 'small' END`
|
||||||
|
// → 返回字符串 "big' END ELSE 'small"(正则把嵌套 CASE 的 ELSE 当成自己的分支
|
||||||
|
// 边界,残片被原样返回给用户);条件解析失败时还 `cond = null` 静默跳过该
|
||||||
|
// 分支 → 整列变成 ELSE 值而不报错。
|
||||||
|
// 新实现复用 `sql/lexer` 的 token 流(带 position)做递归下降,天然支持嵌套与
|
||||||
|
// 字面量内的关键字,并把条件交给与 WHERE **完全相同**的解析器。
|
||||||
/**
|
/**
|
||||||
* 解析聚合函数表达式 —— **唯一**的聚合识别实现。
|
* 解析聚合函数表达式 —— **唯一**的聚合识别实现。
|
||||||
*
|
*
|
||||||
@@ -13117,6 +13553,23 @@ function bareReference(reference) {
|
|||||||
return text;
|
return text;
|
||||||
return text.split('.').pop();
|
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) {
|
function resolveAliasSource(source, row) {
|
||||||
const text = source.trim();
|
const text = source.trim();
|
||||||
// 字符串常量(含 SQL 标准 '' 转义还原)
|
// 字符串常量(含 SQL 标准 '' 转义还原)
|
||||||
@@ -13169,61 +13622,6 @@ function reduceNumeric(values, op) {
|
|||||||
}
|
}
|
||||||
return op === 'AVG' ? acc / values.length : acc;
|
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
|
// Executor
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -13544,6 +13942,13 @@ class QueryExecutor {
|
|||||||
const shape = this.analyzeSelect(stmt);
|
const shape = this.analyzeSelect(stmt);
|
||||||
const { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe } = shape;
|
const { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe } = shape;
|
||||||
let rows;
|
let rows;
|
||||||
|
// v0.8.0(B-4):CASE 表达式里的列引用必须在**分组/聚合之前**校验。
|
||||||
|
//
|
||||||
|
// 时机很关键:分组会把行替换为"分组键 + 聚合值",此后源列已不存在,
|
||||||
|
// 任何基于行的校验都会误报未知列(实测:`GROUP BY CASE ... END` 在投影期
|
||||||
|
// 校验会报 Unknown column "n")。这里按**schema** 建可见列集合,
|
||||||
|
// 因此与行形状无关,天然正确。
|
||||||
|
await this.assertCaseColumnsExist(stmt, isJoinQuery);
|
||||||
// v0.8.0(A37):WHERE 里的列引用也必须校验存在性。
|
// v0.8.0(A37):WHERE 里的列引用也必须校验存在性。
|
||||||
//
|
//
|
||||||
// 投影侧的同类校验(assertProjectionColumnsExist)早已存在,但 WHERE 侧一直
|
// 投影侧的同类校验(assertProjectionColumnsExist)早已存在,但 WHERE 侧一直
|
||||||
@@ -13978,8 +14383,13 @@ class QueryExecutor {
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
// v0.7.4: 类型安全键编码 —— 此前 String(row[col] ?? 'null') 使
|
// v0.7.4: 类型安全键编码 —— 此前 String(row[col] ?? 'null') 使
|
||||||
// null 与字符串 'null' 合并为一组(GROUP BY 静默少组)
|
// null 与字符串 'null' 合并为一组(GROUP BY 静默少组)
|
||||||
|
//
|
||||||
|
// v0.8.0(B-4):分组项可以是**表达式**(CASE),此时按行求值取分组键 ——
|
||||||
|
// 此前一律当列名去查,于是 `GROUP BY CASE ... END` 报
|
||||||
|
// `COLUMN_NOT_FOUND Unknown column "CASE WHEN ..."`(实测),
|
||||||
|
// 而"按条件分组"是 SQL 里最常见的分析写法之一。
|
||||||
const key = groupColumns
|
const key = groupColumns
|
||||||
.map((col) => encodeValueKey(resolveColumnValue(row, col, { strict: true, context: 'GROUP BY' })))
|
.map((col) => encodeValueKey(resolveGroupKeyValue(row, col)))
|
||||||
.join('\x1f');
|
.join('\x1f');
|
||||||
if (!groups.has(key))
|
if (!groups.has(key))
|
||||||
groups.set(key, []);
|
groups.set(key, []);
|
||||||
@@ -13996,7 +14406,16 @@ class QueryExecutor {
|
|||||||
// 分组列以基列名输出(`GROUP BY grp` 引用别名时输出基列 `g`,
|
// 分组列以基列名输出(`GROUP BY grp` 引用别名时输出基列 `g`,
|
||||||
// 与 SELECT 列表里 `g AS grp` 的投影可正确对应)
|
// 与 SELECT 列表里 `g AS grp` 的投影可正确对应)
|
||||||
for (const col of groupColumns) {
|
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 与输出键写入
|
// 先算聚合(含仅 HAVING 引用的),统一以 exprKey 与输出键写入
|
||||||
for (const agg of aggregateExprs) {
|
for (const agg of aggregateExprs) {
|
||||||
@@ -14084,7 +14503,7 @@ class QueryExecutor {
|
|||||||
output[agg.outputKey] = aggregated[agg.outputKey];
|
output[agg.outputKey] = aggregated[agg.outputKey];
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const caseExpr = /^\s*CASE\b/i.test(colExpr) ? parseCaseExpression(colExpr) : null;
|
const caseExpr = parseCaseExpression(colExpr);
|
||||||
if (caseExpr) {
|
if (caseExpr) {
|
||||||
const key = caseExpr.alias ?? colExpr;
|
const key = caseExpr.alias ?? colExpr;
|
||||||
output[key] = aggregated[key];
|
output[key] = aggregated[key];
|
||||||
@@ -14110,6 +14529,13 @@ class QueryExecutor {
|
|||||||
resolveGroupByColumns(stmt) {
|
resolveGroupByColumns(stmt) {
|
||||||
const aliases = new Map();
|
const aliases = new Map();
|
||||||
for (const col of stmt.columns) {
|
for (const col of stmt.columns) {
|
||||||
|
// v0.8.0(B-4):CASE 的 `AS alias` 也要进别名表 ——
|
||||||
|
// `SELECT CASE ... END AS band ... GROUP BY band` 是最常见的条件分组写法。
|
||||||
|
const caseExpr = parseCaseExpression(col);
|
||||||
|
if (caseExpr?.alias) {
|
||||||
|
aliases.set(caseExpr.alias, col.trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const m = col.match(/^(.+?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
|
const m = col.match(/^(.+?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
|
||||||
if (m)
|
if (m)
|
||||||
aliases.set(m[2], m[1].trim());
|
aliases.set(m[2], m[1].trim());
|
||||||
@@ -14153,7 +14579,7 @@ class QueryExecutor {
|
|||||||
*/
|
*/
|
||||||
computeAggregate(func, rows, arg) {
|
computeAggregate(func, rows, arg) {
|
||||||
// v0.3.2: 聚合参数支持 CASE WHEN 表达式(如 SUM(CASE WHEN age > 18 THEN 1 ELSE 0 END))
|
// 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 剥离后传入
|
// v0.4.0: COUNT(DISTINCT col) —— distinct 由 parseAggregateExpression 剥离后传入
|
||||||
const argCol = arg.trim();
|
const argCol = arg.trim();
|
||||||
// v0.8.0(A25):列引用统一走 resolveColumnValue(别名前缀剥离 + 后缀回退)。
|
// v0.8.0(A25):列引用统一走 resolveColumnValue(别名前缀剥离 + 后缀回退)。
|
||||||
@@ -14542,7 +14968,7 @@ class QueryExecutor {
|
|||||||
names.add(agg.outputKey);
|
names.add(agg.outputKey);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const caseExpr = /^\s*CASE\b/i.test(col) ? parseCaseExpression(col) : null;
|
const caseExpr = parseCaseExpression(col);
|
||||||
if (caseExpr) {
|
if (caseExpr) {
|
||||||
names.add(caseExpr.alias ?? col);
|
names.add(caseExpr.alias ?? col);
|
||||||
continue;
|
continue;
|
||||||
@@ -14640,6 +15066,48 @@ class QueryExecutor {
|
|||||||
+ ` in JOIN ON of table "${stmt.from}"`, 'COLUMN_NOT_FOUND', { columns: missing, from: stmt.from });
|
+ ` in JOIN ON of table "${stmt.from}"`, 'COLUMN_NOT_FOUND', { columns: missing, from: stmt.from });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-4):校验 SELECT / GROUP BY / HAVING / ORDER BY 里 CASE 表达式引用的列存在。
|
||||||
|
*
|
||||||
|
* 为什么单独一个方法:CASE 可以出现在四个子句里,而每个子句的校验时机不同
|
||||||
|
* (WHERE 有 `assertWhereColumnsExist`,投影有 `assertProjectionColumnsExist`)。
|
||||||
|
* 统一在这里按 **schema** 收集可见列,与行形状解耦,避免"分组后校验不到源列"。
|
||||||
|
*/
|
||||||
|
async assertCaseColumnsExist(stmt, isJoinQuery) {
|
||||||
|
if (stmt.fromSubquery)
|
||||||
|
return; // 派生表列来自子查询投影,需另行解析
|
||||||
|
if (!stmt.from)
|
||||||
|
return;
|
||||||
|
const available = new Set();
|
||||||
|
const addTable = async (table, alias) => {
|
||||||
|
const schema = await this.engine.getTableSchema(table);
|
||||||
|
if (!schema)
|
||||||
|
return;
|
||||||
|
for (const col of Object.keys(schema.columns)) {
|
||||||
|
available.add(col);
|
||||||
|
available.add(`${alias}.${col}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await addTable(stmt.from, stmt.alias ?? stmt.from);
|
||||||
|
if (isJoinQuery) {
|
||||||
|
for (const join of stmt.joins ?? [])
|
||||||
|
await addTable(join.table, join.alias ?? join.table);
|
||||||
|
}
|
||||||
|
const check = (text, context) => {
|
||||||
|
const caseExpr = parseCaseExpression(text);
|
||||||
|
if (caseExpr)
|
||||||
|
assertCaseColumnsExist(caseExpr, available, context);
|
||||||
|
};
|
||||||
|
for (const col of stmt.columns)
|
||||||
|
check(col, `CASE expression "${col.trim()}"`);
|
||||||
|
for (const groupCol of stmt.groupBy ?? [])
|
||||||
|
check(groupCol, `GROUP BY "${groupCol}"`);
|
||||||
|
for (const order of stmt.orderBy ?? [])
|
||||||
|
check(order.column, `ORDER BY "${order.column}"`);
|
||||||
|
// HAVING 的 CASE 出现在键位(`HAVING CASE ... END = 1`)
|
||||||
|
for (const key of Object.keys(stmt.having ?? {}))
|
||||||
|
check(key, `HAVING "${key}"`);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* v0.8.0(A37):校验 WHERE 中出现的列名都存在于行源。
|
* v0.8.0(A37):校验 WHERE 中出现的列名都存在于行源。
|
||||||
*
|
*
|
||||||
@@ -14843,6 +15311,9 @@ class QueryExecutor {
|
|||||||
const col = raw.trim();
|
const col = raw.trim();
|
||||||
if (col === '*')
|
if (col === '*')
|
||||||
continue;
|
continue;
|
||||||
|
// CASE 表达式在此跳过:它内部的列引用由 assertCaseColumnsExist 在
|
||||||
|
// **分组/聚合之前**(仍在源行形状上)单独校验 —— 此处 rows 可能已被
|
||||||
|
// 分组替换(只剩分组键与聚合值),拿不到源列,会误报未知列。
|
||||||
if (parseCaseExpression(col))
|
if (parseCaseExpression(col))
|
||||||
continue;
|
continue;
|
||||||
let reference = col;
|
let reference = col;
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+589
-118
@@ -11317,58 +11317,69 @@
|
|||||||
/** 读取下一个 Token */
|
/** 读取下一个 Token */
|
||||||
nextToken() {
|
nextToken() {
|
||||||
this.skipWhitespaceAndComments();
|
this.skipWhitespaceAndComments();
|
||||||
|
/**
|
||||||
|
* v0.8.0:token 的**起始位置**在读到任何字符之前记录。
|
||||||
|
*
|
||||||
|
* 为什么必须在这里记:此前的 position 由各 reader 自行回推,语义**按 token
|
||||||
|
* 类型不一致** —— `readString` 用 `start = this.position + 1`(指向引号**之内**),
|
||||||
|
* 而 `readIdentifier`/`readNumber` 用 `this.position - len`(指向首字符)。
|
||||||
|
* 于是 `'big'` 报 position 23 而实际从 24 开始,任何"按 position 切片"的
|
||||||
|
* 调用方都会多切一个字符(实测:CASE 的 THEN 值被切成 `"big'"`)。
|
||||||
|
* 这里统一为"token 首字符在源码中的下标",所有类型一致。
|
||||||
|
*/
|
||||||
|
const tokenStart = this.position;
|
||||||
let tok;
|
let tok;
|
||||||
switch (this.ch) {
|
switch (this.ch) {
|
||||||
case ',':
|
case ',':
|
||||||
tok = this.makeToken(TokenType.COMMA, ',');
|
tok = this.makeToken(TokenType.COMMA, ',', tokenStart);
|
||||||
break;
|
break;
|
||||||
case '(':
|
case '(':
|
||||||
tok = this.makeToken(TokenType.LPAREN, '(');
|
tok = this.makeToken(TokenType.LPAREN, '(', tokenStart);
|
||||||
break;
|
break;
|
||||||
case ')':
|
case ')':
|
||||||
tok = this.makeToken(TokenType.RPAREN, ')');
|
tok = this.makeToken(TokenType.RPAREN, ')', tokenStart);
|
||||||
break;
|
break;
|
||||||
case ';':
|
case ';':
|
||||||
tok = this.makeToken(TokenType.SEMICOLON, ';');
|
tok = this.makeToken(TokenType.SEMICOLON, ';');
|
||||||
break;
|
break;
|
||||||
case '*':
|
case '*':
|
||||||
tok = this.makeToken(TokenType.STAR, '*');
|
tok = this.makeToken(TokenType.STAR, '*', tokenStart);
|
||||||
break;
|
break;
|
||||||
case '.':
|
case '.':
|
||||||
tok = this.makeToken(TokenType.DOT, '.');
|
tok = this.makeToken(TokenType.DOT, '.', tokenStart);
|
||||||
break;
|
break;
|
||||||
case '=':
|
case '=':
|
||||||
tok = this.makeToken(TokenType.EQ, '=');
|
tok = this.makeToken(TokenType.EQ, '=', tokenStart);
|
||||||
break;
|
break;
|
||||||
case '!':
|
case '!':
|
||||||
if (this.peekChar() === '=') {
|
if (this.peekChar() === '=') {
|
||||||
this.readChar();
|
this.readChar();
|
||||||
tok = this.makeToken(TokenType.NEQ, '!=');
|
tok = this.makeToken(TokenType.NEQ, '!=', tokenStart);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
tok = this.makeToken(TokenType.ILLEGAL, '!');
|
tok = this.makeToken(TokenType.ILLEGAL, '!', tokenStart);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case '>':
|
case '>':
|
||||||
if (this.peekChar() === '=') {
|
if (this.peekChar() === '=') {
|
||||||
this.readChar();
|
this.readChar();
|
||||||
tok = this.makeToken(TokenType.GTE, '>=');
|
tok = this.makeToken(TokenType.GTE, '>=', tokenStart);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
tok = this.makeToken(TokenType.GT, '>');
|
tok = this.makeToken(TokenType.GT, '>', tokenStart);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case '<':
|
case '<':
|
||||||
if (this.peekChar() === '=') {
|
if (this.peekChar() === '=') {
|
||||||
this.readChar();
|
this.readChar();
|
||||||
tok = this.makeToken(TokenType.LTE, '<=');
|
tok = this.makeToken(TokenType.LTE, '<=', tokenStart);
|
||||||
}
|
}
|
||||||
else if (this.peekChar() === '>') {
|
else if (this.peekChar() === '>') {
|
||||||
this.readChar();
|
this.readChar();
|
||||||
tok = this.makeToken(TokenType.NEQ, '<>');
|
tok = this.makeToken(TokenType.NEQ, '<>', tokenStart);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
tok = this.makeToken(TokenType.LT, '<');
|
tok = this.makeToken(TokenType.LT, '<', tokenStart);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "'":
|
case "'":
|
||||||
@@ -11385,24 +11396,17 @@
|
|||||||
if (this.isLetter(this.ch)) {
|
if (this.isLetter(this.ch)) {
|
||||||
const ident = this.readIdentifier();
|
const ident = this.readIdentifier();
|
||||||
const keyword = KEYWORDS[ident.toUpperCase()];
|
const keyword = KEYWORDS[ident.toUpperCase()];
|
||||||
tok = {
|
// v0.8.0: 用 tokenStart(首字符下标),不再用 position - len 回推
|
||||||
type: keyword ?? TokenType.IDENTIFIER,
|
tok = { type: keyword ?? TokenType.IDENTIFIER, value: ident, position: tokenStart };
|
||||||
value: ident,
|
|
||||||
position: this.position - ident.length,
|
|
||||||
};
|
|
||||||
return tok; // 已读取完毕,不需要再 readChar
|
return tok; // 已读取完毕,不需要再 readChar
|
||||||
}
|
}
|
||||||
else if (this.isDigit(this.ch) || (this.ch === '-' && this.isDigit(this.peekChar()))) {
|
else if (this.isDigit(this.ch) || (this.ch === '-' && this.isDigit(this.peekChar()))) {
|
||||||
const num = this.readNumber();
|
const num = this.readNumber();
|
||||||
tok = {
|
tok = { type: TokenType.NUMBER, value: num, position: tokenStart };
|
||||||
type: TokenType.NUMBER,
|
|
||||||
value: num,
|
|
||||||
position: this.position - num.length,
|
|
||||||
};
|
|
||||||
return tok;
|
return tok;
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
tok = this.makeToken(TokenType.ILLEGAL, this.ch);
|
tok = this.makeToken(TokenType.ILLEGAL, this.ch, tokenStart);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -11520,7 +11524,11 @@
|
|||||||
* 完全一致,且任意以反斜杠结尾的参数值都能正确绑定。
|
* 完全一致,且任意以反斜杠结尾的参数值都能正确绑定。
|
||||||
*/
|
*/
|
||||||
readString() {
|
readString() {
|
||||||
const start = this.position + 1; // 跳过一个引号
|
// v0.8.0: start = **开引号**的位置(此前 +1 指向引号之内,导致所有按
|
||||||
|
// position 切片的调用方都多切一个字符)。未闭合错误消息里用的 start 仍取
|
||||||
|
// 引号之后的位置,便于用户定位到内容起点。
|
||||||
|
const start = this.position;
|
||||||
|
const contentStart = this.position + 1;
|
||||||
this.readChar(); // 跳过开始引号
|
this.readChar(); // 跳过开始引号
|
||||||
let value = '';
|
let value = '';
|
||||||
while (this.ch !== '') {
|
while (this.ch !== '') {
|
||||||
@@ -11540,7 +11548,7 @@
|
|||||||
// v0.7.2: 未闭合字符串字面量显式报错(此前静默返回残缺 STRING token,
|
// v0.7.2: 未闭合字符串字面量显式报错(此前静默返回残缺 STRING token,
|
||||||
// 上层可解析出错误结果,如 `SELECT 'abc` 被当作合法常量列)
|
// 上层可解析出错误结果,如 `SELECT 'abc` 被当作合法常量列)
|
||||||
if (this.ch === '') {
|
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 {
|
return {
|
||||||
type: TokenType.STRING,
|
type: TokenType.STRING,
|
||||||
@@ -11555,8 +11563,8 @@
|
|||||||
* 未闭合同样显式报错,与字符串字面量保持一致。
|
* 未闭合同样显式报错,与字符串字面量保持一致。
|
||||||
*/
|
*/
|
||||||
readQuotedIdentifier() {
|
readQuotedIdentifier() {
|
||||||
|
// v0.8.0: start = 开引号位置(position 语义统一为 token 首字符)
|
||||||
const start = this.position;
|
const start = this.position;
|
||||||
const contentStart = this.position + 1;
|
|
||||||
this.readChar(); // 跳过开始引号
|
this.readChar(); // 跳过开始引号
|
||||||
let value = '';
|
let value = '';
|
||||||
while (this.ch !== '') {
|
while (this.ch !== '') {
|
||||||
@@ -11581,7 +11589,9 @@
|
|||||||
return {
|
return {
|
||||||
type: TokenType.QUOTED_IDENTIFIER,
|
type: TokenType.QUOTED_IDENTIFIER,
|
||||||
value,
|
value,
|
||||||
position: contentStart,
|
// v0.8.0: 与 STRING 一致 —— position = **开引号**的位置(按 position 切片
|
||||||
|
// 才能取到完整的分隔标识符文本)
|
||||||
|
position: start,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
isLetter(ch) {
|
isLetter(ch) {
|
||||||
@@ -11590,8 +11600,8 @@
|
|||||||
isDigit(ch) {
|
isDigit(ch) {
|
||||||
return /[0-9]/.test(ch);
|
return /[0-9]/.test(ch);
|
||||||
}
|
}
|
||||||
makeToken(type, value) {
|
makeToken(type, value, start = this.position) {
|
||||||
return { type, value, position: this.position };
|
return { type, value, position: start };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -12963,43 +12973,46 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* metona-sqlark Query Executor — AST 执行器
|
* metona-sqlark 列引用取值 —— WHERE / 投影 / 聚合 / 表达式**共用**的唯一实现
|
||||||
* @module query/executor
|
* @module query/column-value
|
||||||
*
|
*
|
||||||
* JOIN / GROUP BY / DISTINCT 逻辑在此层处理。
|
* ============================================================================
|
||||||
|
* 为什么必须只有一个实现(PLAN-v0.7.5.md 根因 1)
|
||||||
|
* ============================================================================
|
||||||
|
* "从一行里按名字取一列"曾是四处各写一份的实现,规则各不相同:
|
||||||
|
*
|
||||||
|
* | 位置 | 别名前缀 | 后缀回退 | 未知列 |
|
||||||
|
* |---|---|---|---|
|
||||||
|
* | `projectColumns`(where-matcher) | 否 | 唯一后缀 | 静默丢键 |
|
||||||
|
* | `resolveAliasSource`(executor) | **否** | 否 | 静默 undefined |
|
||||||
|
* | 聚合参数(executor) | 否 | 否 | **静默计 0**(A25) |
|
||||||
|
* | `matchWhere` 的 `resolveField` | 否 | 唯一后缀(多个则 UNRESOLVED) | UNRESOLVED |
|
||||||
|
*
|
||||||
|
* 于是 `COUNT(t.n)` 返回 0(A25)、`SELECT d.id FROM (...) AS d` 返回空集(A36)、
|
||||||
|
* CASE 的 THEN 分支引用列名时行为随调用点变化(B-4)。
|
||||||
|
*
|
||||||
|
* 现在四处都调用本模块的 `resolveColumnValue`:
|
||||||
|
* - JOIN 行的键是 `alias.col`,单表行的键是 `col` —— 两种形态都要支持;
|
||||||
|
* - 取不到值时**由调用方**决定是抛错还是返回 UNRESOLVED(`strict` 选项),
|
||||||
|
* 因为"WHERE 里的未解析引用"与"表达式里的未解析引用"需要不同的上层处理。
|
||||||
*/
|
*/
|
||||||
/**
|
/**
|
||||||
* v0.8.0: 解析 `expr AS alias` 中的 `expr` 取值来源。
|
* 从行里取一个列引用。
|
||||||
*
|
*
|
||||||
* 此前只处理"字符串常量"与"列引用"两种情况,数字/布尔/NULL 常量会走
|
* 解析顺序(与 ORDER BY 的 `stripAlias` 语义一致,保证同一引用在各子句里等价):
|
||||||
* `row[source]` → undefined,于是 `SELECT 1 AS one FROM t` 返回 `[{}]`
|
* 1. 精确命中(行键与引用完全一致,含 `alias.col` 形态);
|
||||||
* (键在、值为 undefined,JSON 序列化后整个键消失),
|
* 2. 剥离别名前缀(`t.n` → `n`);
|
||||||
* 而这正是 EXISTS 子查询里最常见的写法(`SELECT 1 FROM ...`)。
|
* 3. 唯一后缀匹配(行键是 `t.n` 而引用写作 `n`);
|
||||||
*/
|
* 4. 以上都不中 → 依 `strict` 抛错或返回 `UNRESOLVED`。
|
||||||
/**
|
|
||||||
* 列引用取值(唯一实现)。
|
|
||||||
*
|
*
|
||||||
* v0.8.0(A25):为什么必须有这一个函数。
|
* 多个后缀命中视为**歧义**(JOIN 里两表同名列),`strict` 下抛错 ——
|
||||||
*
|
* 静默取第一个正是"结果取决于表顺序"这类难查问题的来源。
|
||||||
* "从行里取一列"此前散落在 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, reference, opts) {
|
function resolveColumnValue(row, reference, opts) {
|
||||||
const text = reference.trim();
|
const text = reference.trim();
|
||||||
if (text in row)
|
if (text in row)
|
||||||
return row[text];
|
return row[text];
|
||||||
// 别名前缀(`t.n` → `n`):JOIN 行用 `alias.col` 作键,但单表路径的
|
// 别名前缀(`t.n` → `n`):JOIN 行用 `alias.col` 作键,单表路径的键不带前缀
|
||||||
// 行键不带前缀,两种形态都必须支持。
|
|
||||||
if (text.includes('.')) {
|
if (text.includes('.')) {
|
||||||
const bare = text.split('.').pop();
|
const bare = text.split('.').pop();
|
||||||
if (bare in row)
|
if (bare in row)
|
||||||
@@ -13021,8 +13034,431 @@
|
|||||||
? `Ambiguous column "${text}" in ${opts.context}: present in multiple tables`
|
? `Ambiguous column "${text}" in ${opts.context}: present in multiple tables`
|
||||||
: `Unknown column "${text}" in ${opts.context}`, 'COLUMN_NOT_FOUND', { column: text });
|
: `Unknown column "${text}" in ${opts.context}`, 'COLUMN_NOT_FOUND', { column: text });
|
||||||
}
|
}
|
||||||
return undefined;
|
return UNRESOLVED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* metona-sqlark 表达式解析与求值 —— CASE WHEN 的结构化实现(v0.8.0 / B-4)
|
||||||
|
* @module query/expression
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* 为什么必须换掉原实现(PLAN-v0.7.5.md 根因 3:字符串化 AST 表达式列)
|
||||||
|
* ============================================================================
|
||||||
|
* 原 `parseCaseExpression` 用**正则**在原始 SQL 文本上切分 WHEN/THEN/ELSE:
|
||||||
|
*
|
||||||
|
* ```ts
|
||||||
|
* /WHEN\s+([\s\S]*?)\s+THEN\s+([\s\S]*?)(?=\s+WHEN\s+|\s+ELSE\s+|\s*$)/gi
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 它不认字符串字面量、不认嵌套结构,于是实测出三类错误结果:
|
||||||
|
*
|
||||||
|
* | 输入 | 实测(修复前) | 应有 |
|
||||||
|
* |---|---|---|
|
||||||
|
* | `CASE WHEN n>10 THEN CASE WHEN n>25 THEN 'huge' ELSE 'big' END ELSE 'small' END` | `"big' END ELSE 'small"` / `null` | `huge`/`big`/`small` |
|
||||||
|
* | `CASE WHEN s='WHEN' THEN 'hit' ELSE 'miss' END` | 依赖切分点,可能错 | `miss/hit/miss` |
|
||||||
|
*
|
||||||
|
* 更严重的是**静默错值**:条件解析失败(如引用不存在的列)时 `cond = null`,
|
||||||
|
* 求值直接跳过该分支 —— 整列变成 ELSE 值,没有任何提示;而同一列名出现在
|
||||||
|
* WHERE 里会正常报错。同一语义两套行为。
|
||||||
|
*
|
||||||
|
* ============================================================================
|
||||||
|
* 本模块的做法
|
||||||
|
* ============================================================================
|
||||||
|
* 1. **复用 `sql/lexer` 的 token 流**(带 `position`),不另写一套词法规则 ——
|
||||||
|
* 字符串里的 `WHEN`/`ELSE`、转义引号、注释都由它正确处理,
|
||||||
|
* 因此"切分点"不再可能落在字面量内部。
|
||||||
|
* 2. **递归下降**解析 CASE(天然支持嵌套),并把每个片段按**源码位置切片**,
|
||||||
|
* 交给既有的 `parseWhereCondition` / 字面量解析器处理 ——
|
||||||
|
* 条件语义与 WHERE 完全同源,不再各写一份。
|
||||||
|
* 3. **解析失败即报错**(`PARSE_ERROR` / `COLUMN_NOT_FOUND`),不静默降级为
|
||||||
|
* 某一个分支的值。
|
||||||
|
* 4. 解析结果**缓存**:`parseCaseExpression` 是纯函数,可安全记忆化;
|
||||||
|
* 聚合与逐行投影会对同一表达式反复求值(N 行 × M 次),缓存把解析开销
|
||||||
|
* 从 O(行数) 降到 O(1)。
|
||||||
|
*
|
||||||
|
* 注意:本模块只负责 **CASE** 表达式。普通列引用/字面量/聚合由
|
||||||
|
* `query/executor` 的 `resolveColumnValue` 与 `parseAggregateExpression`
|
||||||
|
* 处理(B-4 的另一半已由 A25 统一)。
|
||||||
|
*/
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 解析
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/** 解析缓存:同一段文本只解析一次(纯函数,可安全记忆化) */
|
||||||
|
const caseParseCache = new Map();
|
||||||
|
/** 缓存上限(防御性:避免长生命周期进程里无界增长) */
|
||||||
|
const CASE_CACHE_LIMIT = 512;
|
||||||
|
/**
|
||||||
|
* 解析 CASE WHEN 表达式文本。
|
||||||
|
*
|
||||||
|
* @param expr 形如 `CASE WHEN a > 1 THEN 'x' ELSE 'y' END AS band` 的片段
|
||||||
|
* @returns 结构化表达式;**不是** CASE 表达式时返回 null(调用方据此走其它分支)
|
||||||
|
* @throws DatabaseError 结构不完整(缺 THEN/END)或条件无法解析
|
||||||
|
*/
|
||||||
|
function parseCaseExpression(expr) {
|
||||||
|
const text = expr.trim();
|
||||||
|
if (!/^\s*CASE\b/i.test(text))
|
||||||
|
return null;
|
||||||
|
const cached = caseParseCache.get(text);
|
||||||
|
if (cached)
|
||||||
|
return cached;
|
||||||
|
const parsed = parseCaseExpressionUncached(text);
|
||||||
|
// 先清理再写入:条目数达到上限时整表清空,避免无界增长。
|
||||||
|
// 用 LRU 会引入额外状态;解析本身是纯函数且调用点集中在少数表达式上,
|
||||||
|
// "清空重建"足够且没有正确性风险。
|
||||||
|
if (caseParseCache.size >= CASE_CACHE_LIMIT)
|
||||||
|
caseParseCache.clear();
|
||||||
|
caseParseCache.set(text, parsed);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 真正的解析实现。
|
||||||
|
*
|
||||||
|
* 用 `tokenize` 得到带位置的 token 流后按源码切片 —— 这样"条件/值片段"与
|
||||||
|
* 原始 SQL 逐字符一致(含引号与转义),可以安全地交给 `parseWhereCondition`。
|
||||||
|
*/
|
||||||
|
function parseCaseExpressionUncached(text) {
|
||||||
|
const tokens = tokenize(text);
|
||||||
|
let i = 0;
|
||||||
|
const fail = (message, position) => {
|
||||||
|
throw new DatabaseError(`${message} (at offset ${position} in "${text}")`, 'PARSE_ERROR');
|
||||||
|
};
|
||||||
|
// CASE
|
||||||
|
if (tokens[i]?.type !== TokenType.CASE)
|
||||||
|
fail('Expected CASE', tokens[i]?.position ?? 0);
|
||||||
|
i++;
|
||||||
|
const branches = [];
|
||||||
|
let elseText = null;
|
||||||
|
/**
|
||||||
|
* 按源码位置取片段。
|
||||||
|
*
|
||||||
|
* 边界必须用"**下一个 token 的起始位置**"作为右开区间:
|
||||||
|
* STRING token 的 `position` 指向**引号之内**(lexer 的 readString 里
|
||||||
|
* `start = position + 1`),用 `position + value.length` 之类算术会把
|
||||||
|
* 结尾引号切掉(实测:`'small'` 被切成 `"small'"`,CASE 全部报
|
||||||
|
* NOT_SUPPORTED —— 这正是本函数必须存在的理由)。
|
||||||
|
* 同理 NUMBER/IDENTIFIER 的 position 也由各自 reader 回推,语义不完全统一;
|
||||||
|
* 只有"下一个 token 的起点"是对所有 token 类型都成立的边界。
|
||||||
|
*/
|
||||||
|
const slice = (startIndex, endIndexExclusive) => {
|
||||||
|
const startTok = tokens[startIndex];
|
||||||
|
if (!startTok)
|
||||||
|
return '';
|
||||||
|
const endTok = tokens[endIndexExclusive];
|
||||||
|
const endPos = endTok ? endTok.position : text.length;
|
||||||
|
return text.slice(startTok.position, endPos).trim();
|
||||||
|
};
|
||||||
|
while (i < tokens.length) {
|
||||||
|
const tok = tokens[i];
|
||||||
|
if (tok.type === TokenType.WHEN) {
|
||||||
|
const condStart = i + 1;
|
||||||
|
// 找与之配对的 THEN:跳过嵌套的括号(CASE 内的子查询/括号表达式)
|
||||||
|
let depth = 0;
|
||||||
|
let thenIndex = -1;
|
||||||
|
for (let j = condStart; j < tokens.length; j++) {
|
||||||
|
const t = tokens[j];
|
||||||
|
if (t.type === TokenType.LPAREN)
|
||||||
|
depth++;
|
||||||
|
else if (t.type === TokenType.RPAREN)
|
||||||
|
depth--;
|
||||||
|
else if (depth === 0 && t.type === TokenType.THEN) {
|
||||||
|
thenIndex = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (depth === 0 && (t.type === TokenType.ELSE || t.type === TokenType.END))
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (thenIndex < 0)
|
||||||
|
fail('CASE WHEN without matching THEN', tok.position);
|
||||||
|
const valueStart = thenIndex + 1;
|
||||||
|
// 值的结束点:下一个同级 WHEN / ELSE / END
|
||||||
|
let depth2 = 0;
|
||||||
|
let valueEnd = tokens.length;
|
||||||
|
for (let j = valueStart; j < tokens.length; j++) {
|
||||||
|
const t = tokens[j];
|
||||||
|
if (t.type === TokenType.LPAREN)
|
||||||
|
depth2++;
|
||||||
|
else if (t.type === TokenType.RPAREN)
|
||||||
|
depth2--;
|
||||||
|
else if (depth2 === 0 && (t.type === TokenType.WHEN || t.type === TokenType.ELSE || t.type === TokenType.END)) {
|
||||||
|
valueEnd = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (depth2 === 0 && t.type === TokenType.CASE) {
|
||||||
|
// 嵌套 CASE 作为一个整体:跳到与它配对的 END
|
||||||
|
let nestedDepth = 1;
|
||||||
|
for (let k = j + 1; k < tokens.length; k++) {
|
||||||
|
if (tokens[k].type === TokenType.CASE)
|
||||||
|
nestedDepth++;
|
||||||
|
else if (tokens[k].type === TokenType.END) {
|
||||||
|
nestedDepth--;
|
||||||
|
if (nestedDepth === 0) {
|
||||||
|
j = k;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const conditionText = slice(condStart, thenIndex);
|
||||||
|
const resultText = slice(valueStart, valueEnd);
|
||||||
|
if (!conditionText)
|
||||||
|
fail('CASE WHEN has an empty condition', tok.position);
|
||||||
|
if (!resultText)
|
||||||
|
fail('CASE THEN has an empty result', tokens[valueStart]?.position ?? tok.position);
|
||||||
|
// 条件在**解析期**交给与 WHERE 完全相同的解析器 —— 语义同源,
|
||||||
|
// 且"引用不存在的列"之类问题按 WHERE 的口径处理(不再静默跳过分支)。
|
||||||
|
let condition;
|
||||||
|
try {
|
||||||
|
condition = parseWhereCondition(conditionText);
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
throw new DatabaseError(`Invalid CASE WHEN condition "${conditionText}": ${error.message}`, 'PARSE_ERROR', { condition: conditionText });
|
||||||
|
}
|
||||||
|
branches.push({ conditionText, resultText, condition });
|
||||||
|
i = valueEnd;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tok.type === TokenType.ELSE) {
|
||||||
|
const elseStart = i + 1;
|
||||||
|
let depth = 0;
|
||||||
|
let elseEnd = tokens.length;
|
||||||
|
for (let j = elseStart; j < tokens.length; j++) {
|
||||||
|
const t = tokens[j];
|
||||||
|
if (t.type === TokenType.LPAREN)
|
||||||
|
depth++;
|
||||||
|
else if (t.type === TokenType.RPAREN)
|
||||||
|
depth--;
|
||||||
|
else if (depth === 0 && t.type === TokenType.END) {
|
||||||
|
elseEnd = j;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
else if (depth === 0 && t.type === TokenType.CASE) {
|
||||||
|
let nestedDepth = 1;
|
||||||
|
for (let k = j + 1; k < tokens.length; k++) {
|
||||||
|
if (tokens[k].type === TokenType.CASE)
|
||||||
|
nestedDepth++;
|
||||||
|
else if (tokens[k].type === TokenType.END) {
|
||||||
|
nestedDepth--;
|
||||||
|
if (nestedDepth === 0) {
|
||||||
|
j = k;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
elseText = slice(elseStart, elseEnd);
|
||||||
|
if (!elseText)
|
||||||
|
fail('CASE ELSE has an empty result', tok.position);
|
||||||
|
i = elseEnd;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (tok.type === TokenType.END) {
|
||||||
|
i++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
fail(`Unexpected token "${tok.value}" in CASE`, tok.position);
|
||||||
|
}
|
||||||
|
if (branches.length === 0) {
|
||||||
|
throw new DatabaseError(`CASE expression has no WHEN branch: "${text}"`, 'PARSE_ERROR');
|
||||||
|
}
|
||||||
|
// 可选别名:END AS alias / END alias
|
||||||
|
let alias = null;
|
||||||
|
const rest = tokens.slice(i).filter((t) => t.type !== TokenType.EOF && t.type !== TokenType.SEMICOLON);
|
||||||
|
if (rest.length > 0) {
|
||||||
|
const first = rest[0];
|
||||||
|
if (first.type === TokenType.AS) {
|
||||||
|
const aliasTok = rest[1];
|
||||||
|
if (aliasTok && (aliasTok.type === TokenType.IDENTIFIER || aliasTok.type === TokenType.QUOTED_IDENTIFIER)) {
|
||||||
|
alias = aliasTok.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (first.type === TokenType.IDENTIFIER || first.type === TokenType.QUOTED_IDENTIFIER) {
|
||||||
|
alias = first.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { branches, elseText, alias, source: text };
|
||||||
|
}
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 求值
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 对一行求值 CASE 表达式。
|
||||||
|
*
|
||||||
|
* 求值顺序即声明顺序:第一个条件为 TRUE 的分支胜出;
|
||||||
|
* 无分支命中时取 ELSE(未写 ELSE 则为 NULL)。
|
||||||
|
*
|
||||||
|
* 条件用 `matchWhere`(三值逻辑)判定:UNKNOWN **不**算命中
|
||||||
|
* (与 WHERE 只保留 TRUE 的语义一致)。
|
||||||
|
*/
|
||||||
|
function evaluateCase(expr, row) {
|
||||||
|
for (const branch of expr.branches) {
|
||||||
|
if (matchWhere(row, branch.condition, { $col: true })) {
|
||||||
|
return evaluateExpressionValue(branch.resultText, row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return expr.elseText !== null ? evaluateExpressionValue(expr.elseText, row) : null;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 校验 CASE 条件里引用的列在行源中存在(v0.8.0 / B-4)。
|
||||||
|
*
|
||||||
|
* 为什么必须有:条件用 `matchWhere` 求值时,**引用不存在的列**只会得到
|
||||||
|
* UNKNOWN(三值逻辑的正确行为 —— 引擎层拿不到 schema),于是该分支永不命中,
|
||||||
|
* 整列静默变成 ELSE 值。实测修复前:
|
||||||
|
* `CASE WHEN nope > 1 THEN 'x' ELSE 'y' END` → 每行都是 'y',无任何报错;
|
||||||
|
* 而同一个 `nope` 写在 WHERE 里会正常抛 COLUMN_NOT_FOUND。
|
||||||
|
* 同一语义两套行为,且失败方向是"静默错值"。
|
||||||
|
*
|
||||||
|
* 与 `assertWhereColumnsExist` 的关系:那条路径校验的是 SQL 的 WHERE 子句,
|
||||||
|
* 它拿得到表名与别名;CASE 出现在 SELECT/GROUP BY/HAVING 里,调用点更分散,
|
||||||
|
* 因此这里做**独立的、可复用的**校验,由调用方在有 schema 时调用。
|
||||||
|
*
|
||||||
|
* @param available 该作用域内可见的列名集合(含 `alias.col` 形态;JOIN 时是两表并集)
|
||||||
|
*/
|
||||||
|
function assertCaseColumnsExist(expr, available, context) {
|
||||||
|
const missing = [];
|
||||||
|
const checkRef = (ref) => {
|
||||||
|
const text = ref.trim();
|
||||||
|
if (!text)
|
||||||
|
return;
|
||||||
|
if (available.has(text))
|
||||||
|
return;
|
||||||
|
const bare = text.includes('.') ? text.split('.').pop() : text;
|
||||||
|
if (available.has(bare))
|
||||||
|
return;
|
||||||
|
missing.push(text);
|
||||||
|
};
|
||||||
|
// 条件里的列引用:键位(`n > 1`)与 `$col` 值位(`a = b`)
|
||||||
|
const walkCondition = (cond) => {
|
||||||
|
for (const [key, value] of Object.entries(cond)) {
|
||||||
|
if (key === '$and' || key === '$or') {
|
||||||
|
for (const sub of (Array.isArray(value) ? value : [value]))
|
||||||
|
walkCondition(sub);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === '$not') {
|
||||||
|
walkCondition(value);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (key === '$exists' || /^\s*CASE\b/i.test(key))
|
||||||
|
continue;
|
||||||
|
checkRef(key);
|
||||||
|
if (value !== null && typeof value === 'object') {
|
||||||
|
for (const operand of Object.values(value)) {
|
||||||
|
if (operand !== null && typeof operand === 'object' && !Array.isArray(operand) && '$col' in operand) {
|
||||||
|
checkRef(String(operand.$col));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const branch of expr.branches)
|
||||||
|
walkCondition(branch.condition);
|
||||||
|
// 结果片段里的列引用(嵌套 CASE 递归)
|
||||||
|
const checkResult = (text) => {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (/^'/.test(trimmed) || /^-?\d/.test(trimmed) || /^(NULL|TRUE|FALSE)$/i.test(trimmed))
|
||||||
|
return;
|
||||||
|
const nested = parseCaseExpression(trimmed);
|
||||||
|
if (nested) {
|
||||||
|
for (const b of nested.branches)
|
||||||
|
walkCondition(b.condition);
|
||||||
|
for (const b of nested.branches)
|
||||||
|
checkResult(b.resultText);
|
||||||
|
if (nested.elseText)
|
||||||
|
checkResult(nested.elseText);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(trimmed))
|
||||||
|
checkRef(trimmed);
|
||||||
|
};
|
||||||
|
for (const branch of expr.branches)
|
||||||
|
checkResult(branch.resultText);
|
||||||
|
if (expr.elseText)
|
||||||
|
checkResult(expr.elseText);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw new DatabaseError(`Unknown column${missing.length > 1 ? 's' : ''} ${missing.map((c) => `"${c}"`).join(', ')}`
|
||||||
|
+ ` in ${context}`, 'COLUMN_NOT_FOUND', { columns: missing });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* 求值一个"值表达式"片段(THEN/ELSE 的操作数)。
|
||||||
|
*
|
||||||
|
* 支持的形态(覆盖既有全部用例,不引入静默降级):
|
||||||
|
* - 字符串字面量(含 `''` 转义)、数字、TRUE/FALSE/NULL;
|
||||||
|
* - 列引用(裸列名或 `表.列`);
|
||||||
|
* - **嵌套 CASE**(递归求值);
|
||||||
|
* - 其它无法识别的文本 → 抛 `NOT_SUPPORTED`,而不是"当字符串返回"
|
||||||
|
* (原实现把无法识别的文本原样返回,于是嵌套 CASE 的残片
|
||||||
|
* `"big' END ELSE 'small"` 变成了用户可见的返回值)。
|
||||||
|
*/
|
||||||
|
function evaluateExpressionValue(text, row) {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed === '') {
|
||||||
|
throw new DatabaseError('Empty expression value', 'PARSE_ERROR');
|
||||||
|
}
|
||||||
|
// 字符串字面量(SQL 标准 '' 转义)
|
||||||
|
const strLit = trimmed.match(/^'(.*)'$/s);
|
||||||
|
if (strLit)
|
||||||
|
return strLit[1].replace(/''/g, "'");
|
||||||
|
// NULL / 布尔
|
||||||
|
if (/^NULL$/i.test(trimmed))
|
||||||
|
return null;
|
||||||
|
if (/^TRUE$/i.test(trimmed))
|
||||||
|
return true;
|
||||||
|
if (/^FALSE$/i.test(trimmed))
|
||||||
|
return false;
|
||||||
|
// 数字常量(含负号与小数)
|
||||||
|
if (/^-?\d+(\.\d+)?$/.test(trimmed))
|
||||||
|
return Number(trimmed);
|
||||||
|
// 嵌套 CASE
|
||||||
|
const nested = parseCaseExpression(trimmed);
|
||||||
|
if (nested)
|
||||||
|
return evaluateCase(nested, row);
|
||||||
|
// 列引用(含 `表.列`)—— 未知列抛 COLUMN_NOT_FOUND(与投影路径同口径)
|
||||||
|
if (/^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)?$/.test(trimmed)) {
|
||||||
|
const value = resolveColumnValue(row, trimmed, { strict: false, context: 'CASE result' });
|
||||||
|
if (isUnresolved(value)) {
|
||||||
|
throw new DatabaseError(`Unknown column "${trimmed}" in expression`, 'COLUMN_NOT_FOUND', {
|
||||||
|
column: trimmed,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
throw new DatabaseError(`Unsupported expression in CASE result: "${trimmed}"`, 'NOT_SUPPORTED', { expression: trimmed });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* metona-sqlark Query Executor — AST 执行器
|
||||||
|
* @module query/executor
|
||||||
|
*
|
||||||
|
* JOIN / GROUP BY / DISTINCT 逻辑在此层处理。
|
||||||
|
*/
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 分组 / 去重键编码(v0.7.4)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// 分组 / 去重键
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// v0.8.0(B-2):`encodeValueKey` 已删除 —— 它曾是**第四份**值编码实现
|
||||||
|
// (`sql-compare.ts` 的 `encodeValueKey`、Aria 索引键、COUNT(DISTINCT) 各有其一)。
|
||||||
|
// 四份编码对 null/undefined 的处理各不相同,于是"同两个值在 GROUP BY 相等、
|
||||||
|
// 在 DISTINCT 不等"这类跨路径矛盾无法根除。现在统一使用 `encodeValueKey`:
|
||||||
|
// 类型前缀 + 长度前缀,null 与 undefined 同为 SQL NULL(合并),
|
||||||
|
// 且编码不会与数据内容冲突。
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// CASE WHEN 表达式
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// v0.8.0(B-4):解析与求值已迁移到 `query/expression.ts`。
|
||||||
|
//
|
||||||
|
// 为什么必须搬走:原实现用**正则**在 SQL 文本上切分 WHEN/THEN/ELSE,不认字符串
|
||||||
|
// 字面量与嵌套结构,实测出静默错值:
|
||||||
|
// `CASE WHEN n>10 THEN CASE WHEN n>25 THEN 'huge' ELSE 'big' END ELSE 'small' END`
|
||||||
|
// → 返回字符串 "big' END ELSE 'small"(正则把嵌套 CASE 的 ELSE 当成自己的分支
|
||||||
|
// 边界,残片被原样返回给用户);条件解析失败时还 `cond = null` 静默跳过该
|
||||||
|
// 分支 → 整列变成 ELSE 值而不报错。
|
||||||
|
// 新实现复用 `sql/lexer` 的 token 流(带 position)做递归下降,天然支持嵌套与
|
||||||
|
// 字面量内的关键字,并把条件交给与 WHERE **完全相同**的解析器。
|
||||||
/**
|
/**
|
||||||
* 解析聚合函数表达式 —— **唯一**的聚合识别实现。
|
* 解析聚合函数表达式 —— **唯一**的聚合识别实现。
|
||||||
*
|
*
|
||||||
@@ -13123,6 +13559,23 @@
|
|||||||
return text;
|
return text;
|
||||||
return text.split('.').pop();
|
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) {
|
function resolveAliasSource(source, row) {
|
||||||
const text = source.trim();
|
const text = source.trim();
|
||||||
// 字符串常量(含 SQL 标准 '' 转义还原)
|
// 字符串常量(含 SQL 标准 '' 转义还原)
|
||||||
@@ -13175,61 +13628,6 @@
|
|||||||
}
|
}
|
||||||
return op === 'AVG' ? acc / values.length : acc;
|
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
|
// Executor
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -13550,6 +13948,13 @@
|
|||||||
const shape = this.analyzeSelect(stmt);
|
const shape = this.analyzeSelect(stmt);
|
||||||
const { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe } = shape;
|
const { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe } = shape;
|
||||||
let rows;
|
let rows;
|
||||||
|
// v0.8.0(B-4):CASE 表达式里的列引用必须在**分组/聚合之前**校验。
|
||||||
|
//
|
||||||
|
// 时机很关键:分组会把行替换为"分组键 + 聚合值",此后源列已不存在,
|
||||||
|
// 任何基于行的校验都会误报未知列(实测:`GROUP BY CASE ... END` 在投影期
|
||||||
|
// 校验会报 Unknown column "n")。这里按**schema** 建可见列集合,
|
||||||
|
// 因此与行形状无关,天然正确。
|
||||||
|
await this.assertCaseColumnsExist(stmt, isJoinQuery);
|
||||||
// v0.8.0(A37):WHERE 里的列引用也必须校验存在性。
|
// v0.8.0(A37):WHERE 里的列引用也必须校验存在性。
|
||||||
//
|
//
|
||||||
// 投影侧的同类校验(assertProjectionColumnsExist)早已存在,但 WHERE 侧一直
|
// 投影侧的同类校验(assertProjectionColumnsExist)早已存在,但 WHERE 侧一直
|
||||||
@@ -13984,8 +14389,13 @@
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
// v0.7.4: 类型安全键编码 —— 此前 String(row[col] ?? 'null') 使
|
// v0.7.4: 类型安全键编码 —— 此前 String(row[col] ?? 'null') 使
|
||||||
// null 与字符串 'null' 合并为一组(GROUP BY 静默少组)
|
// null 与字符串 'null' 合并为一组(GROUP BY 静默少组)
|
||||||
|
//
|
||||||
|
// v0.8.0(B-4):分组项可以是**表达式**(CASE),此时按行求值取分组键 ——
|
||||||
|
// 此前一律当列名去查,于是 `GROUP BY CASE ... END` 报
|
||||||
|
// `COLUMN_NOT_FOUND Unknown column "CASE WHEN ..."`(实测),
|
||||||
|
// 而"按条件分组"是 SQL 里最常见的分析写法之一。
|
||||||
const key = groupColumns
|
const key = groupColumns
|
||||||
.map((col) => encodeValueKey(resolveColumnValue(row, col, { strict: true, context: 'GROUP BY' })))
|
.map((col) => encodeValueKey(resolveGroupKeyValue(row, col)))
|
||||||
.join('\x1f');
|
.join('\x1f');
|
||||||
if (!groups.has(key))
|
if (!groups.has(key))
|
||||||
groups.set(key, []);
|
groups.set(key, []);
|
||||||
@@ -14002,7 +14412,16 @@
|
|||||||
// 分组列以基列名输出(`GROUP BY grp` 引用别名时输出基列 `g`,
|
// 分组列以基列名输出(`GROUP BY grp` 引用别名时输出基列 `g`,
|
||||||
// 与 SELECT 列表里 `g AS grp` 的投影可正确对应)
|
// 与 SELECT 列表里 `g AS grp` 的投影可正确对应)
|
||||||
for (const col of groupColumns) {
|
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 与输出键写入
|
// 先算聚合(含仅 HAVING 引用的),统一以 exprKey 与输出键写入
|
||||||
for (const agg of aggregateExprs) {
|
for (const agg of aggregateExprs) {
|
||||||
@@ -14090,7 +14509,7 @@
|
|||||||
output[agg.outputKey] = aggregated[agg.outputKey];
|
output[agg.outputKey] = aggregated[agg.outputKey];
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const caseExpr = /^\s*CASE\b/i.test(colExpr) ? parseCaseExpression(colExpr) : null;
|
const caseExpr = parseCaseExpression(colExpr);
|
||||||
if (caseExpr) {
|
if (caseExpr) {
|
||||||
const key = caseExpr.alias ?? colExpr;
|
const key = caseExpr.alias ?? colExpr;
|
||||||
output[key] = aggregated[key];
|
output[key] = aggregated[key];
|
||||||
@@ -14116,6 +14535,13 @@
|
|||||||
resolveGroupByColumns(stmt) {
|
resolveGroupByColumns(stmt) {
|
||||||
const aliases = new Map();
|
const aliases = new Map();
|
||||||
for (const col of stmt.columns) {
|
for (const col of stmt.columns) {
|
||||||
|
// v0.8.0(B-4):CASE 的 `AS alias` 也要进别名表 ——
|
||||||
|
// `SELECT CASE ... END AS band ... GROUP BY band` 是最常见的条件分组写法。
|
||||||
|
const caseExpr = parseCaseExpression(col);
|
||||||
|
if (caseExpr?.alias) {
|
||||||
|
aliases.set(caseExpr.alias, col.trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const m = col.match(/^(.+?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
|
const m = col.match(/^(.+?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
|
||||||
if (m)
|
if (m)
|
||||||
aliases.set(m[2], m[1].trim());
|
aliases.set(m[2], m[1].trim());
|
||||||
@@ -14159,7 +14585,7 @@
|
|||||||
*/
|
*/
|
||||||
computeAggregate(func, rows, arg) {
|
computeAggregate(func, rows, arg) {
|
||||||
// v0.3.2: 聚合参数支持 CASE WHEN 表达式(如 SUM(CASE WHEN age > 18 THEN 1 ELSE 0 END))
|
// 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 剥离后传入
|
// v0.4.0: COUNT(DISTINCT col) —— distinct 由 parseAggregateExpression 剥离后传入
|
||||||
const argCol = arg.trim();
|
const argCol = arg.trim();
|
||||||
// v0.8.0(A25):列引用统一走 resolveColumnValue(别名前缀剥离 + 后缀回退)。
|
// v0.8.0(A25):列引用统一走 resolveColumnValue(别名前缀剥离 + 后缀回退)。
|
||||||
@@ -14548,7 +14974,7 @@
|
|||||||
names.add(agg.outputKey);
|
names.add(agg.outputKey);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const caseExpr = /^\s*CASE\b/i.test(col) ? parseCaseExpression(col) : null;
|
const caseExpr = parseCaseExpression(col);
|
||||||
if (caseExpr) {
|
if (caseExpr) {
|
||||||
names.add(caseExpr.alias ?? col);
|
names.add(caseExpr.alias ?? col);
|
||||||
continue;
|
continue;
|
||||||
@@ -14646,6 +15072,48 @@
|
|||||||
+ ` in JOIN ON of table "${stmt.from}"`, 'COLUMN_NOT_FOUND', { columns: missing, from: stmt.from });
|
+ ` in JOIN ON of table "${stmt.from}"`, 'COLUMN_NOT_FOUND', { columns: missing, from: stmt.from });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-4):校验 SELECT / GROUP BY / HAVING / ORDER BY 里 CASE 表达式引用的列存在。
|
||||||
|
*
|
||||||
|
* 为什么单独一个方法:CASE 可以出现在四个子句里,而每个子句的校验时机不同
|
||||||
|
* (WHERE 有 `assertWhereColumnsExist`,投影有 `assertProjectionColumnsExist`)。
|
||||||
|
* 统一在这里按 **schema** 收集可见列,与行形状解耦,避免"分组后校验不到源列"。
|
||||||
|
*/
|
||||||
|
async assertCaseColumnsExist(stmt, isJoinQuery) {
|
||||||
|
if (stmt.fromSubquery)
|
||||||
|
return; // 派生表列来自子查询投影,需另行解析
|
||||||
|
if (!stmt.from)
|
||||||
|
return;
|
||||||
|
const available = new Set();
|
||||||
|
const addTable = async (table, alias) => {
|
||||||
|
const schema = await this.engine.getTableSchema(table);
|
||||||
|
if (!schema)
|
||||||
|
return;
|
||||||
|
for (const col of Object.keys(schema.columns)) {
|
||||||
|
available.add(col);
|
||||||
|
available.add(`${alias}.${col}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await addTable(stmt.from, stmt.alias ?? stmt.from);
|
||||||
|
if (isJoinQuery) {
|
||||||
|
for (const join of stmt.joins ?? [])
|
||||||
|
await addTable(join.table, join.alias ?? join.table);
|
||||||
|
}
|
||||||
|
const check = (text, context) => {
|
||||||
|
const caseExpr = parseCaseExpression(text);
|
||||||
|
if (caseExpr)
|
||||||
|
assertCaseColumnsExist(caseExpr, available, context);
|
||||||
|
};
|
||||||
|
for (const col of stmt.columns)
|
||||||
|
check(col, `CASE expression "${col.trim()}"`);
|
||||||
|
for (const groupCol of stmt.groupBy ?? [])
|
||||||
|
check(groupCol, `GROUP BY "${groupCol}"`);
|
||||||
|
for (const order of stmt.orderBy ?? [])
|
||||||
|
check(order.column, `ORDER BY "${order.column}"`);
|
||||||
|
// HAVING 的 CASE 出现在键位(`HAVING CASE ... END = 1`)
|
||||||
|
for (const key of Object.keys(stmt.having ?? {}))
|
||||||
|
check(key, `HAVING "${key}"`);
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* v0.8.0(A37):校验 WHERE 中出现的列名都存在于行源。
|
* v0.8.0(A37):校验 WHERE 中出现的列名都存在于行源。
|
||||||
*
|
*
|
||||||
@@ -14849,6 +15317,9 @@
|
|||||||
const col = raw.trim();
|
const col = raw.trim();
|
||||||
if (col === '*')
|
if (col === '*')
|
||||||
continue;
|
continue;
|
||||||
|
// CASE 表达式在此跳过:它内部的列引用由 assertCaseColumnsExist 在
|
||||||
|
// **分组/聚合之前**(仍在源行形状上)单独校验 —— 此处 rows 可能已被
|
||||||
|
// 分组替换(只剩分组键与聚合值),拿不到源列,会误报未知列。
|
||||||
if (parseCaseExpression(col))
|
if (parseCaseExpression(col))
|
||||||
continue;
|
continue;
|
||||||
let reference = col;
|
let reference = col;
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -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)` 返回 0(A25)、`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
@@ -16,8 +16,12 @@ import { DatabaseError } from '../constants';
|
|||||||
import { compileStatement } from './compiler';
|
import { compileStatement } from './compiler';
|
||||||
import { createSchema, astColumnToColumnDef } from '../table/schema';
|
import { createSchema, astColumnToColumnDef } from '../table/schema';
|
||||||
import { matchWhere, applyOrderBy, projectColumns, containsUnresolvedSubqueries } from './where-matcher';
|
import { matchWhere, applyOrderBy, projectColumns, containsUnresolvedSubqueries } from './where-matcher';
|
||||||
import { parseWhereCondition } from '../sql/parser';
|
|
||||||
import { encodeValueKey } from './sql-compare';
|
import { encodeValueKey } from './sql-compare';
|
||||||
|
import {
|
||||||
|
parseCaseExpression, evaluateCase,
|
||||||
|
assertCaseColumnsExist, type CaseExpression,
|
||||||
|
} from './expression';
|
||||||
|
import { resolveColumnValue } from './column-value';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// SELECT 执行形态(v0.8.0)
|
// SELECT 执行形态(v0.8.0)
|
||||||
@@ -65,85 +69,20 @@ import type { WhereCondition } from '../constants';
|
|||||||
// 且编码不会与数据内容冲突。
|
// 且编码不会与数据内容冲突。
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// CASE WHEN 表达式(v0.3.1)
|
// CASE WHEN 表达式
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// v0.8.0(B-4):解析与求值已迁移到 `query/expression.ts`。
|
||||||
|
//
|
||||||
|
// 为什么必须搬走:原实现用**正则**在 SQL 文本上切分 WHEN/THEN/ELSE,不认字符串
|
||||||
|
// 字面量与嵌套结构,实测出静默错值:
|
||||||
|
// `CASE WHEN n>10 THEN CASE WHEN n>25 THEN 'huge' ELSE 'big' END ELSE 'small' END`
|
||||||
|
// → 返回字符串 "big' END ELSE 'small"(正则把嵌套 CASE 的 ELSE 当成自己的分支
|
||||||
|
// 边界,残片被原样返回给用户);条件解析失败时还 `cond = null` 静默跳过该
|
||||||
|
// 分支 → 整列变成 ELSE 值而不报错。
|
||||||
|
// 新实现复用 `sql/lexer` 的 token 流(带 position)做递归下降,天然支持嵌套与
|
||||||
|
// 字面量内的关键字,并把条件交给与 WHERE **完全相同**的解析器。
|
||||||
|
|
||||||
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` 返回 `[{}]`
|
|
||||||
* (键在、值为 undefined,JSON 序列化后整个键消失),
|
|
||||||
* 而这正是 EXISTS 子查询里最常见的写法(`SELECT 1 FROM ...`)。
|
|
||||||
*/
|
|
||||||
/**
|
|
||||||
* 列引用取值(唯一实现)。
|
|
||||||
*
|
|
||||||
* v0.8.0(A25):为什么必须有这一个函数。
|
|
||||||
*
|
|
||||||
* "从行里取一列"此前散落在 4 处、规则各不相同:
|
|
||||||
* - `projectColumns`(where-matcher)精确匹配 + 唯一后缀匹配;
|
|
||||||
* - `resolveAliasSource` 只做 `row[text]`,**没有**后缀回退;
|
|
||||||
* - 聚合参数直接 `r[argCol]`,既没有后缀回退,也没有"列不存在"的报错;
|
|
||||||
* - `matchWhere` 的 `resolveField` 又有自己的一套。
|
|
||||||
* 于是 `SELECT COUNT(t.n) FROM t` 返回 0:行键是 `n`,而聚合参数是 `t.n`,
|
|
||||||
* 直接取值得到 undefined,再被 `.filter(v => v != null)` 剔除 —— 计数为 0,
|
|
||||||
* **不报错**。而同一个 `t.n` 写在 SELECT 列表里(`SELECT t.n`)却正常,
|
|
||||||
* 因为那条路径会先剥离别名前缀。
|
|
||||||
*
|
|
||||||
* 本函数统一三件事:别名前缀剥离 → 精确匹配 → 唯一后缀匹配;
|
|
||||||
* 都不命中时由调用方决定是抛错还是返回 undefined(`onMissing`)。
|
|
||||||
*/
|
|
||||||
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()!;
|
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 {
|
function resolveAliasSource(source: string, row: Record<string, unknown>): unknown {
|
||||||
const text = source.trim();
|
const text = source.trim();
|
||||||
// 字符串常量(含 SQL 标准 '' 转义还原)
|
// 字符串常量(含 SQL 标准 '' 转义还原)
|
||||||
@@ -286,58 +242,6 @@ function reduceNumeric(values: number[], op: 'SUM' | 'AVG' | 'MIN' | 'MAX'): num
|
|||||||
return op === 'AVG' ? acc / values.length : acc;
|
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
|
// Executor
|
||||||
@@ -683,6 +587,14 @@ export class QueryExecutor {
|
|||||||
const { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe } = shape;
|
const { hasGroupBy, hasAggregate, isJoinQuery, needsRawRows, orderByAlias, hasSelectAlias, limitPushdownSafe } = shape;
|
||||||
let rows: Record<string, unknown>[];
|
let rows: Record<string, unknown>[];
|
||||||
|
|
||||||
|
// v0.8.0(B-4):CASE 表达式里的列引用必须在**分组/聚合之前**校验。
|
||||||
|
//
|
||||||
|
// 时机很关键:分组会把行替换为"分组键 + 聚合值",此后源列已不存在,
|
||||||
|
// 任何基于行的校验都会误报未知列(实测:`GROUP BY CASE ... END` 在投影期
|
||||||
|
// 校验会报 Unknown column "n")。这里按**schema** 建可见列集合,
|
||||||
|
// 因此与行形状无关,天然正确。
|
||||||
|
await this.assertCaseColumnsExist(stmt, isJoinQuery);
|
||||||
|
|
||||||
// v0.8.0(A37):WHERE 里的列引用也必须校验存在性。
|
// v0.8.0(A37):WHERE 里的列引用也必须校验存在性。
|
||||||
//
|
//
|
||||||
// 投影侧的同类校验(assertProjectionColumnsExist)早已存在,但 WHERE 侧一直
|
// 投影侧的同类校验(assertProjectionColumnsExist)早已存在,但 WHERE 侧一直
|
||||||
@@ -1115,8 +1027,13 @@ export class QueryExecutor {
|
|||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
// v0.7.4: 类型安全键编码 —— 此前 String(row[col] ?? 'null') 使
|
// v0.7.4: 类型安全键编码 —— 此前 String(row[col] ?? 'null') 使
|
||||||
// null 与字符串 'null' 合并为一组(GROUP BY 静默少组)
|
// null 与字符串 'null' 合并为一组(GROUP BY 静默少组)
|
||||||
|
//
|
||||||
|
// v0.8.0(B-4):分组项可以是**表达式**(CASE),此时按行求值取分组键 ——
|
||||||
|
// 此前一律当列名去查,于是 `GROUP BY CASE ... END` 报
|
||||||
|
// `COLUMN_NOT_FOUND Unknown column "CASE WHEN ..."`(实测),
|
||||||
|
// 而"按条件分组"是 SQL 里最常见的分析写法之一。
|
||||||
const key = groupColumns
|
const key = groupColumns
|
||||||
.map((col) => encodeValueKey(resolveColumnValue(row, col, { strict: true, context: 'GROUP BY' })))
|
.map((col) => encodeValueKey(resolveGroupKeyValue(row, col)))
|
||||||
.join('\x1f');
|
.join('\x1f');
|
||||||
if (!groups.has(key)) groups.set(key, []);
|
if (!groups.has(key)) groups.set(key, []);
|
||||||
groups.get(key)!.push(row);
|
groups.get(key)!.push(row);
|
||||||
@@ -1134,7 +1051,16 @@ export class QueryExecutor {
|
|||||||
// 分组列以基列名输出(`GROUP BY grp` 引用别名时输出基列 `g`,
|
// 分组列以基列名输出(`GROUP BY grp` 引用别名时输出基列 `g`,
|
||||||
// 与 SELECT 列表里 `g AS grp` 的投影可正确对应)
|
// 与 SELECT 列表里 `g AS grp` 的投影可正确对应)
|
||||||
for (const col of groupColumns) {
|
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 与输出键写入
|
// 先算聚合(含仅 HAVING 引用的),统一以 exprKey 与输出键写入
|
||||||
@@ -1222,7 +1148,7 @@ export class QueryExecutor {
|
|||||||
output[agg.outputKey] = aggregated[agg.outputKey];
|
output[agg.outputKey] = aggregated[agg.outputKey];
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const caseExpr = /^\s*CASE\b/i.test(colExpr) ? parseCaseExpression(colExpr) : null;
|
const caseExpr = parseCaseExpression(colExpr);
|
||||||
if (caseExpr) {
|
if (caseExpr) {
|
||||||
const key = caseExpr.alias ?? colExpr;
|
const key = caseExpr.alias ?? colExpr;
|
||||||
output[key] = aggregated[key];
|
output[key] = aggregated[key];
|
||||||
@@ -1249,6 +1175,13 @@ export class QueryExecutor {
|
|||||||
private resolveGroupByColumns(stmt: SelectStatement): string[] {
|
private resolveGroupByColumns(stmt: SelectStatement): string[] {
|
||||||
const aliases = new Map<string, string>();
|
const aliases = new Map<string, string>();
|
||||||
for (const col of stmt.columns) {
|
for (const col of stmt.columns) {
|
||||||
|
// v0.8.0(B-4):CASE 的 `AS alias` 也要进别名表 ——
|
||||||
|
// `SELECT CASE ... END AS band ... GROUP BY band` 是最常见的条件分组写法。
|
||||||
|
const caseExpr = parseCaseExpression(col);
|
||||||
|
if (caseExpr?.alias) {
|
||||||
|
aliases.set(caseExpr.alias, col.trim());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const m = col.match(/^(.+?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
|
const m = col.match(/^(.+?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
|
||||||
if (m) aliases.set(m[2], m[1].trim());
|
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 {
|
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))
|
// 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 剥离后传入
|
// v0.4.0: COUNT(DISTINCT col) —— distinct 由 parseAggregateExpression 剥离后传入
|
||||||
const argCol = arg.trim();
|
const argCol = arg.trim();
|
||||||
// v0.8.0(A25):列引用统一走 resolveColumnValue(别名前缀剥离 + 后缀回退)。
|
// v0.8.0(A25):列引用统一走 resolveColumnValue(别名前缀剥离 + 后缀回退)。
|
||||||
@@ -1729,7 +1662,7 @@ export class QueryExecutor {
|
|||||||
names.add(agg.outputKey);
|
names.add(agg.outputKey);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const caseExpr = /^\s*CASE\b/i.test(col) ? parseCaseExpression(col) : null;
|
const caseExpr = parseCaseExpression(col);
|
||||||
if (caseExpr) {
|
if (caseExpr) {
|
||||||
names.add(caseExpr.alias ?? col);
|
names.add(caseExpr.alias ?? col);
|
||||||
continue;
|
continue;
|
||||||
@@ -1825,6 +1758,42 @@ export class QueryExecutor {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-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.0(A37):校验 WHERE 中出现的列名都存在于行源。
|
* v0.8.0(A37):校验 WHERE 中出现的列名都存在于行源。
|
||||||
*
|
*
|
||||||
@@ -2032,6 +2001,9 @@ export class QueryExecutor {
|
|||||||
for (const raw of columns) {
|
for (const raw of columns) {
|
||||||
const col = raw.trim();
|
const col = raw.trim();
|
||||||
if (col === '*') continue;
|
if (col === '*') continue;
|
||||||
|
// CASE 表达式在此跳过:它内部的列引用由 assertCaseColumnsExist 在
|
||||||
|
// **分组/聚合之前**(仍在源行形状上)单独校验 —— 此处 rows 可能已被
|
||||||
|
// 分组替换(只剩分组键与聚合值),拿不到源列,会误报未知列。
|
||||||
if (parseCaseExpression(col)) continue;
|
if (parseCaseExpression(col)) continue;
|
||||||
|
|
||||||
let reference = col;
|
let reference = col;
|
||||||
|
|||||||
@@ -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
@@ -27,55 +27,67 @@ export class Lexer {
|
|||||||
nextToken(): Token {
|
nextToken(): Token {
|
||||||
this.skipWhitespaceAndComments();
|
this.skipWhitespaceAndComments();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.8.0:token 的**起始位置**在读到任何字符之前记录。
|
||||||
|
*
|
||||||
|
* 为什么必须在这里记:此前的 position 由各 reader 自行回推,语义**按 token
|
||||||
|
* 类型不一致** —— `readString` 用 `start = this.position + 1`(指向引号**之内**),
|
||||||
|
* 而 `readIdentifier`/`readNumber` 用 `this.position - len`(指向首字符)。
|
||||||
|
* 于是 `'big'` 报 position 23 而实际从 24 开始,任何"按 position 切片"的
|
||||||
|
* 调用方都会多切一个字符(实测:CASE 的 THEN 值被切成 `"big'"`)。
|
||||||
|
* 这里统一为"token 首字符在源码中的下标",所有类型一致。
|
||||||
|
*/
|
||||||
|
const tokenStart = this.position;
|
||||||
|
|
||||||
let tok: Token;
|
let tok: Token;
|
||||||
|
|
||||||
switch (this.ch) {
|
switch (this.ch) {
|
||||||
case ',':
|
case ',':
|
||||||
tok = this.makeToken(TokenType.COMMA, ',');
|
tok = this.makeToken(TokenType.COMMA, ',', tokenStart);
|
||||||
break;
|
break;
|
||||||
case '(':
|
case '(':
|
||||||
tok = this.makeToken(TokenType.LPAREN, '(');
|
tok = this.makeToken(TokenType.LPAREN, '(', tokenStart);
|
||||||
break;
|
break;
|
||||||
case ')':
|
case ')':
|
||||||
tok = this.makeToken(TokenType.RPAREN, ')');
|
tok = this.makeToken(TokenType.RPAREN, ')', tokenStart);
|
||||||
break;
|
break;
|
||||||
case ';':
|
case ';':
|
||||||
tok = this.makeToken(TokenType.SEMICOLON, ';');
|
tok = this.makeToken(TokenType.SEMICOLON, ';');
|
||||||
break;
|
break;
|
||||||
case '*':
|
case '*':
|
||||||
tok = this.makeToken(TokenType.STAR, '*');
|
tok = this.makeToken(TokenType.STAR, '*', tokenStart);
|
||||||
break;
|
break;
|
||||||
case '.':
|
case '.':
|
||||||
tok = this.makeToken(TokenType.DOT, '.');
|
tok = this.makeToken(TokenType.DOT, '.', tokenStart);
|
||||||
break;
|
break;
|
||||||
case '=':
|
case '=':
|
||||||
tok = this.makeToken(TokenType.EQ, '=');
|
tok = this.makeToken(TokenType.EQ, '=', tokenStart);
|
||||||
break;
|
break;
|
||||||
case '!':
|
case '!':
|
||||||
if (this.peekChar() === '=') {
|
if (this.peekChar() === '=') {
|
||||||
this.readChar();
|
this.readChar();
|
||||||
tok = this.makeToken(TokenType.NEQ, '!=');
|
tok = this.makeToken(TokenType.NEQ, '!=', tokenStart);
|
||||||
} else {
|
} else {
|
||||||
tok = this.makeToken(TokenType.ILLEGAL, '!');
|
tok = this.makeToken(TokenType.ILLEGAL, '!', tokenStart);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case '>':
|
case '>':
|
||||||
if (this.peekChar() === '=') {
|
if (this.peekChar() === '=') {
|
||||||
this.readChar();
|
this.readChar();
|
||||||
tok = this.makeToken(TokenType.GTE, '>=');
|
tok = this.makeToken(TokenType.GTE, '>=', tokenStart);
|
||||||
} else {
|
} else {
|
||||||
tok = this.makeToken(TokenType.GT, '>');
|
tok = this.makeToken(TokenType.GT, '>', tokenStart);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case '<':
|
case '<':
|
||||||
if (this.peekChar() === '=') {
|
if (this.peekChar() === '=') {
|
||||||
this.readChar();
|
this.readChar();
|
||||||
tok = this.makeToken(TokenType.LTE, '<=');
|
tok = this.makeToken(TokenType.LTE, '<=', tokenStart);
|
||||||
} else if (this.peekChar() === '>') {
|
} else if (this.peekChar() === '>') {
|
||||||
this.readChar();
|
this.readChar();
|
||||||
tok = this.makeToken(TokenType.NEQ, '<>');
|
tok = this.makeToken(TokenType.NEQ, '<>', tokenStart);
|
||||||
} else {
|
} else {
|
||||||
tok = this.makeToken(TokenType.LT, '<');
|
tok = this.makeToken(TokenType.LT, '<', tokenStart);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "'":
|
case "'":
|
||||||
@@ -92,22 +104,15 @@ export class Lexer {
|
|||||||
if (this.isLetter(this.ch)) {
|
if (this.isLetter(this.ch)) {
|
||||||
const ident = this.readIdentifier();
|
const ident = this.readIdentifier();
|
||||||
const keyword = KEYWORDS[ident.toUpperCase()];
|
const keyword = KEYWORDS[ident.toUpperCase()];
|
||||||
tok = {
|
// v0.8.0: 用 tokenStart(首字符下标),不再用 position - len 回推
|
||||||
type: keyword ?? TokenType.IDENTIFIER,
|
tok = { type: keyword ?? TokenType.IDENTIFIER, value: ident, position: tokenStart };
|
||||||
value: ident,
|
|
||||||
position: this.position - ident.length,
|
|
||||||
};
|
|
||||||
return tok; // 已读取完毕,不需要再 readChar
|
return tok; // 已读取完毕,不需要再 readChar
|
||||||
} else if (this.isDigit(this.ch) || (this.ch === '-' && this.isDigit(this.peekChar()))) {
|
} else if (this.isDigit(this.ch) || (this.ch === '-' && this.isDigit(this.peekChar()))) {
|
||||||
const num = this.readNumber();
|
const num = this.readNumber();
|
||||||
tok = {
|
tok = { type: TokenType.NUMBER, value: num, position: tokenStart };
|
||||||
type: TokenType.NUMBER,
|
|
||||||
value: num,
|
|
||||||
position: this.position - num.length,
|
|
||||||
};
|
|
||||||
return tok;
|
return tok;
|
||||||
} else {
|
} else {
|
||||||
tok = this.makeToken(TokenType.ILLEGAL, this.ch);
|
tok = this.makeToken(TokenType.ILLEGAL, this.ch, tokenStart);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -235,7 +240,11 @@ export class Lexer {
|
|||||||
* 完全一致,且任意以反斜杠结尾的参数值都能正确绑定。
|
* 完全一致,且任意以反斜杠结尾的参数值都能正确绑定。
|
||||||
*/
|
*/
|
||||||
private readString(): Token {
|
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(); // 跳过开始引号
|
this.readChar(); // 跳过开始引号
|
||||||
let value = '';
|
let value = '';
|
||||||
|
|
||||||
@@ -258,7 +267,7 @@ export class Lexer {
|
|||||||
// 上层可解析出错误结果,如 `SELECT 'abc` 被当作合法常量列)
|
// 上层可解析出错误结果,如 `SELECT 'abc` 被当作合法常量列)
|
||||||
if (this.ch === '') {
|
if (this.ch === '') {
|
||||||
throw new DatabaseError(
|
throw new DatabaseError(
|
||||||
`Unterminated string literal at position ${start}`,
|
`Unterminated string literal at position ${contentStart}`,
|
||||||
'PARSE_ERROR',
|
'PARSE_ERROR',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -277,8 +286,8 @@ export class Lexer {
|
|||||||
* 未闭合同样显式报错,与字符串字面量保持一致。
|
* 未闭合同样显式报错,与字符串字面量保持一致。
|
||||||
*/
|
*/
|
||||||
private readQuotedIdentifier(): Token {
|
private readQuotedIdentifier(): Token {
|
||||||
|
// v0.8.0: start = 开引号位置(position 语义统一为 token 首字符)
|
||||||
const start = this.position;
|
const start = this.position;
|
||||||
const contentStart = this.position + 1;
|
|
||||||
this.readChar(); // 跳过开始引号
|
this.readChar(); // 跳过开始引号
|
||||||
let value = '';
|
let value = '';
|
||||||
|
|
||||||
@@ -313,7 +322,9 @@ export class Lexer {
|
|||||||
return {
|
return {
|
||||||
type: TokenType.QUOTED_IDENTIFIER,
|
type: TokenType.QUOTED_IDENTIFIER,
|
||||||
value,
|
value,
|
||||||
position: contentStart,
|
// v0.8.0: 与 STRING 一致 —— position = **开引号**的位置(按 position 切片
|
||||||
|
// 才能取到完整的分隔标识符文本)
|
||||||
|
position: start,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -325,8 +336,8 @@ export class Lexer {
|
|||||||
return /[0-9]/.test(ch);
|
return /[0-9]/.test(ch);
|
||||||
}
|
}
|
||||||
|
|
||||||
private makeToken(type: TokenType, value: string): Token {
|
private makeToken(type: TokenType, value: string, start: number = this.position): Token {
|
||||||
return { type, value, position: this.position };
|
return { type, value, position: start };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 → small(n=1 / NULL),id=2/3 → big(n=20/30)
|
||||||
|
// `band DESC` → 'small' > 'big'(字典序),故 small 组在前,组内按 id 升序
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query(
|
||||||
|
"SELECT id, CASE WHEN n > 10 THEN 'big' ELSE 'small' END AS band FROM t ORDER BY band DESC, id",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(rows.map((r) => r.id)).toEqual(['1', '4', '2', '3']);
|
||||||
|
// 升序则反过来
|
||||||
|
const asc = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query(
|
||||||
|
"SELECT id, CASE WHEN n > 10 THEN 'big' ELSE 'small' END AS band FROM t ORDER BY band, id",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(asc.map((r) => r.id)).toEqual(['2', '3', '1', '4']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user