feat(B-5): 输出列序号 + 分隔标识符语义统一
两个相关缺陷:
① **输出列序号不被支持**(SQL 标准特性)
`ORDER BY 1` / `GROUP BY 2` 此前直接 `PARSE_ERROR: Expected identifier, got "1"`
(实测)。这在手写 SQL 与 UNION 里很常用 —— 各分支输出列名可能不同,只能按
序号引用。序号含义依赖 SELECT 列表,故 parser 只保留数字文本,由 executor
在拿到列表后解析。
规则(与 SQLite/标准一致):裸数字在范围内 → 位置;越界 → QUERY_ERROR
(不说"未知列",问题出在位置而非名字);`GROUP BY <序号>` 指向聚合表达式 →
QUERY_ERROR(按聚合值分组语义上不成立);`ORDER BY 0` / `1.5` → PARSE_ERROR。
② **分隔标识符(`"1"`)在投影期被当成常量**
`CREATE TABLE q ("1" STRING ...)` 后 `SELECT "1" FROM q` 返回 `{"1": 1}`
(**字面量 1**),而 `SELECT *` 返回正确的 `{"1": "z"}` —— 同一列两种结论。
根因:parser 把 `"1"` 的引号丢掉,投影期的"裸数字 = 常量列"子句就把它吃了。
修复的关键设计(一致性是这里唯一的难点,实测踩了四次漂移):
**引号只在"解析 → 执行"的边界脱去,且必须在同一处、对同一批引用统一处理。**
parser 必须保留引号才能区分"名为 1 的列"(`"1"`)与"常量 1"(`1`);
而校验/取值/排序/分组必须用真实列名。此前"校验用裸名、投影用带引号名"两套规则
导致 `1` 与 `"1"` 两个键并存。现在统一在 `normalizeUnprefixedReferences`
与 `projectRow`/`projectColumns`/`resolveGroupKeyValue` 的对应分支脱引号,
并新增可复用的 `unquoteIdentifier`。
同时修正 `projectColumns`(where-matcher,被四个引擎共用):此前"找不到列就不产出
键",现在支持分隔标识符与 `别名.列` 的唯一后缀匹配;"是否未知列"仍由 executor 的
`assertProjectionColumnsExist` 判定并报错,投影层不做静默兜底。
验证:新增 tests/v080-output-ordinals.test.ts(64 项:序号四引擎 40 + 分隔标识符
四引擎 24),覆盖 DESC/多键/LIMIT+OFFSET/UNION/别名共存/越界/非法序号/
WHERE 与 GROUP BY 引用分隔标识符/优先级。
全量 91 套件 / 1870 测试通过;typecheck、lint、build 零错误零告警;dist 已重建。
This commit is contained in:
Vendored
+262
-19
@@ -1008,23 +1008,49 @@ function compare(a, b) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 列投影
|
// 列投影
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 列投影。
|
||||||
|
*
|
||||||
|
* v0.8.0(B-5):支持**分隔标识符**(`"1"`)与 JOIN 行的 `别名.列` 键。
|
||||||
|
* 此前只做"精确命中,否则找 `endsWith('.col')`",于是:
|
||||||
|
* - `SELECT "1" FROM q`(列名就叫 1)取不到值 → 输出 `{}`
|
||||||
|
* (校验用裸名、投影用带引号的名,两套规则 —— 典型漂移);
|
||||||
|
* - 找不到时**不产出键**,行形状随列是否存在而变。
|
||||||
|
* 现在统一:脱引号 → 精确命中 → 唯一后缀命中;仍找不到则不产出该键
|
||||||
|
*(是否"未知列"由 executor 的 assertProjectionColumnsExist 判定并报错,
|
||||||
|
* 投影层不做静默兜底)。
|
||||||
|
*/
|
||||||
function projectColumns(row, columns) {
|
function projectColumns(row, columns) {
|
||||||
const projected = {};
|
const projected = {};
|
||||||
for (const col of columns) {
|
for (const col of columns) {
|
||||||
if (col in row) {
|
const name = unquoteIdentifier$1(col);
|
||||||
projected[col] = row[col];
|
if (name in row) {
|
||||||
|
projected[name] = row[name];
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
else {
|
// JOIN 行键形如 `t.col`:唯一后缀匹配(多个命中视为歧义,取第一个与
|
||||||
|
// executor 的校验口径一致 —— 那里已对歧义报错,能走到这里说明唯一)
|
||||||
|
let found;
|
||||||
|
let hits = 0;
|
||||||
for (const key of Object.keys(row)) {
|
for (const key of Object.keys(row)) {
|
||||||
if (key.endsWith(`.${col}`) || key === col) {
|
if (key.endsWith(`.${name}`) || key === name) {
|
||||||
projected[col] = row[key];
|
found = row[key];
|
||||||
break;
|
hits += 1;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (hits >= 1)
|
||||||
|
projected[name] = found;
|
||||||
}
|
}
|
||||||
return projected;
|
return projected;
|
||||||
}
|
}
|
||||||
|
/** 脱去分隔标识符的引号(`"1"` → `1`,`"a""b"` → `a"b`) */
|
||||||
|
function unquoteIdentifier$1(text) {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||||
|
return trimmed.slice(1, -1).replace(/""/g, '"');
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* metona-sqlark 统一行校验 —— 存储写入的**唯一**验证与规范化入口(v0.8.0)
|
* metona-sqlark 统一行校验 —— 存储写入的**唯一**验证与规范化入口(v0.8.0)
|
||||||
@@ -11925,7 +11951,7 @@ class Parser {
|
|||||||
if (this.curTokenIs(TokenType.GROUP)) {
|
if (this.curTokenIs(TokenType.GROUP)) {
|
||||||
this.nextToken();
|
this.nextToken();
|
||||||
this.expect(TokenType.BY);
|
this.expect(TokenType.BY);
|
||||||
stmt.groupBy = this.parseIdentifierList();
|
stmt.groupBy = this.parseGroupByList();
|
||||||
}
|
}
|
||||||
// HAVING(可选)
|
// HAVING(可选)
|
||||||
if (this.curTokenIs(TokenType.HAVING)) {
|
if (this.curTokenIs(TokenType.HAVING)) {
|
||||||
@@ -12717,6 +12743,24 @@ class Parser {
|
|||||||
this.curTokenIs(TokenType.MAX)) {
|
this.curTokenIs(TokenType.MAX)) {
|
||||||
return this.parseAggregateCall();
|
return this.parseAggregateCall();
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-5):分隔标识符必须**保留引号**。
|
||||||
|
*
|
||||||
|
* 此前 `SELECT "1" FROM q`(列名就叫 `1`,建表时用引号声明)被解析成裸 `1`,
|
||||||
|
* 而投影阶段把裸数字当**常量**列 → 返回 `{"1": 1}`(字面量 1),
|
||||||
|
* 而 `SELECT *` 返回正确的 `{"1": "z"}` —— 同一列两种结论(实测)。
|
||||||
|
* 保留引号后,下游能区分"名为 1 的列"与"常量 1"。
|
||||||
|
*/
|
||||||
|
if (this.curTokenIs(TokenType.QUOTED_IDENTIFIER)) {
|
||||||
|
const name = this.curToken.value;
|
||||||
|
this.nextToken();
|
||||||
|
if (this.curTokenIs(TokenType.DOT)) {
|
||||||
|
this.nextToken();
|
||||||
|
const second = this.expectIdentifier('column name after "."');
|
||||||
|
return `"${name.replace(/"/g, '""')}".${second}`;
|
||||||
|
}
|
||||||
|
return `"${name.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
const first = this.expectIdentifier('column name');
|
const first = this.expectIdentifier('column name');
|
||||||
if (this.curTokenIs(TokenType.DOT)) {
|
if (this.curTokenIs(TokenType.DOT)) {
|
||||||
this.nextToken();
|
this.nextToken();
|
||||||
@@ -12816,6 +12860,40 @@ class Parser {
|
|||||||
}
|
}
|
||||||
return vals;
|
return vals;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-5):ORDER BY / GROUP BY 的键可以是**输出列序号**(SQL 标准)。
|
||||||
|
*
|
||||||
|
* `ORDER BY 1` 表示"按第 1 个输出列排序",`GROUP BY 2` 同理 ——
|
||||||
|
* 这在手写 SQL 与 UNION 里非常常用(各分支输出列名可能不同,只能按序号引用)。
|
||||||
|
*
|
||||||
|
* 此前 parser 的 `parseIdentifierWithDot` 只接受标识符,于是 `ORDER BY 1`
|
||||||
|
* 直接 `PARSE_ERROR: Expected identifier, got "1"`(实测)。
|
||||||
|
*
|
||||||
|
* 这里把序号**原样保留为数字字符串**(AST 形状不变),由 executor 在拿到
|
||||||
|
* SELECT 列表后再解析成对应表达式 —— 序号的含义依赖 SELECT 列表,
|
||||||
|
* parser 层无从判断。
|
||||||
|
*/
|
||||||
|
parseOrderOrGroupKey() {
|
||||||
|
if (this.curTokenIs(TokenType.NUMBER)) {
|
||||||
|
const value = this.curToken.value;
|
||||||
|
// 序号必须是正整数(`ORDER BY 0` / `ORDER BY 1.5` 非法)
|
||||||
|
if (!/^\d+$/.test(value) || Number(value) < 1) {
|
||||||
|
throw this.error(`Invalid output column ordinal "${value}" (must be a positive integer)`);
|
||||||
|
}
|
||||||
|
this.nextToken();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return this.parseIdentifierWithDot();
|
||||||
|
}
|
||||||
|
parseGroupByList() {
|
||||||
|
const list = [];
|
||||||
|
list.push(this.parseOrderOrGroupKey());
|
||||||
|
while (this.curTokenIs(TokenType.COMMA)) {
|
||||||
|
this.nextToken();
|
||||||
|
list.push(this.parseOrderOrGroupKey());
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
parseOrderByList() {
|
parseOrderByList() {
|
||||||
const list = [];
|
const list = [];
|
||||||
list.push(this.parseOrderBy());
|
list.push(this.parseOrderBy());
|
||||||
@@ -12826,7 +12904,7 @@ class Parser {
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
parseOrderBy() {
|
parseOrderBy() {
|
||||||
const column = this.parseIdentifierWithDot();
|
const column = this.parseOrderOrGroupKey();
|
||||||
let direction = 'asc';
|
let direction = 'asc';
|
||||||
if (this.curTokenIs(TokenType.ASC)) {
|
if (this.curTokenIs(TokenType.ASC)) {
|
||||||
this.nextToken();
|
this.nextToken();
|
||||||
@@ -13007,7 +13085,13 @@ function parseWhereCondition(sql) {
|
|||||||
* 静默取第一个正是"结果取决于表顺序"这类难查问题的来源。
|
* 静默取第一个正是"结果取决于表顺序"这类难查问题的来源。
|
||||||
*/
|
*/
|
||||||
function resolveColumnValue(row, reference, opts) {
|
function resolveColumnValue(row, reference, opts) {
|
||||||
const text = reference.trim();
|
let text = reference.trim();
|
||||||
|
// v0.8.0:分隔标识符(`"col"`)在比较/取值时要脱去引号 ——
|
||||||
|
// 保留引号是为了让投影阶段能区分"名为 1 的列"与"常量 1"(见 parser 说明),
|
||||||
|
// 但比较/取值必须用真实列名。`""` 是引号自身的转义。
|
||||||
|
if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) {
|
||||||
|
text = text.slice(1, -1).replace(/""/g, '"');
|
||||||
|
}
|
||||||
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` 作键,单表路径的键不带前缀
|
||||||
@@ -13572,7 +13656,29 @@ function resolveGroupKeyValue(row, item) {
|
|||||||
const caseExpr = parseCaseExpression(item);
|
const caseExpr = parseCaseExpression(item);
|
||||||
if (caseExpr)
|
if (caseExpr)
|
||||||
return evaluateCase(caseExpr, row);
|
return evaluateCase(caseExpr, row);
|
||||||
return resolveColumnValue(row, item, { strict: true, context: 'GROUP BY' });
|
// 脱引号后再取值:解析层保留引号是为了区分"列 vs 常量",执行期必须用真实列名
|
||||||
|
//(否则 `GROUP BY "1"` 会在行里产出重复的 `1` 与 `"1"` 两个键 —— 实测)
|
||||||
|
return resolveColumnValue(row, unquoteIdentifier(item), { strict: true, context: 'GROUP BY' });
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-5):脱去分隔标识符的引号 —— `"1"` → `1`,`"a""b"` → `a"b`。
|
||||||
|
*
|
||||||
|
* 为什么 parser 保留引号、这里再脱:parser 必须保留才能区分
|
||||||
|
* "名为 1 的列"(`"1"`)与"常量 1"(`1`);而一旦进入执行期,列名就是
|
||||||
|
* schema 里的裸名字。**统一在这一个边界脱引号**,避免"投影用带引号的名字、
|
||||||
|
* 取值用裸名字"这类两套规则(那正是本项目反复出现的缺陷模式)。
|
||||||
|
*/
|
||||||
|
function unquoteIdentifier(text) {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||||
|
return trimmed.slice(1, -1).replace(/""/g, '"');
|
||||||
|
}
|
||||||
|
// `"alias"."col"` 形态:两侧都脱引号
|
||||||
|
if (trimmed.includes('.')) {
|
||||||
|
const parts = trimmed.split('.');
|
||||||
|
return parts.map((part) => unquoteIdentifier(part)).join('.');
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
}
|
}
|
||||||
function resolveAliasSource(source, row) {
|
function resolveAliasSource(source, row) {
|
||||||
const text = source.trim();
|
const text = source.trim();
|
||||||
@@ -13977,6 +14083,7 @@ class QueryExecutor {
|
|||||||
// 此前这里**不做**归一化:`SELECT d.id FROM (SELECT id, g FROM t) AS d`
|
// 此前这里**不做**归一化:`SELECT d.id FROM (SELECT id, g FROM t) AS d`
|
||||||
// 直接拿 `d.id` 去投影,行里只有 `id` → 静默返回 `[]`;
|
// 直接拿 `d.id` 去投影,行里只有 `id` → 静默返回 `[]`;
|
||||||
// 而同一查询写成 `SELECT id ...` 却正确 —— 同一行源两种写法结论相反。
|
// 而同一查询写成 `SELECT id ...` 却正确 —— 同一行源两种写法结论相反。
|
||||||
|
await this.resolveOutputOrdinals(stmt);
|
||||||
this.normalizeUnprefixedReferences(stmt, [stmt.alias ?? stmt.from]);
|
this.normalizeUnprefixedReferences(stmt, [stmt.alias ?? stmt.from]);
|
||||||
if (stmt.where && Object.keys(stmt.where).length > 0) {
|
if (stmt.where && Object.keys(stmt.where).length > 0) {
|
||||||
// 非 JOIN:WHERE 在 executor 端过滤(子查询结果不经引擎)
|
// 非 JOIN:WHERE 在 executor 端过滤(子查询结果不经引擎)
|
||||||
@@ -13994,6 +14101,10 @@ class QueryExecutor {
|
|||||||
rows = await this.executeJoinSelect(stmt);
|
rows = await this.executeJoinSelect(stmt);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
// v0.8.0(B-5):先把 ORDER BY / GROUP BY 的输出列序号解析为输出列名。
|
||||||
|
// 必须在归一化之前:序号 → 列名的映射依赖 SELECT 列表原文
|
||||||
|
//(`SELECT u.n FROM t u ORDER BY 1` → `u.n`,归一化后再解析会丢前缀)。
|
||||||
|
await this.resolveOutputOrdinals(stmt);
|
||||||
// 非 JOIN 路径:行键不带别名前缀 → 统一归一化引用
|
// 非 JOIN 路径:行键不带别名前缀 → 统一归一化引用
|
||||||
const mainAliases = [stmt.alias ?? stmt.from].filter(Boolean);
|
const mainAliases = [stmt.alias ?? stmt.from].filter(Boolean);
|
||||||
this.normalizeUnprefixedReferences(stmt, mainAliases);
|
this.normalizeUnprefixedReferences(stmt, mainAliases);
|
||||||
@@ -14418,7 +14529,7 @@ class QueryExecutor {
|
|||||||
// → 用**别名** `band`,因为 projectGroupedRow 按别名索引;
|
// → 用**别名** `band`,因为 projectGroupedRow 按别名索引;
|
||||||
// - 裸列 → 用列名。
|
// - 裸列 → 用列名。
|
||||||
const caseExpr = parseCaseExpression(col);
|
const caseExpr = parseCaseExpression(col);
|
||||||
const key = caseExpr?.alias ?? col;
|
const key = caseExpr?.alias ?? unquoteIdentifier(col);
|
||||||
aggregated[key] = resolveGroupKeyValue(first, col);
|
aggregated[key] = resolveGroupKeyValue(first, col);
|
||||||
}
|
}
|
||||||
// 先算聚合(含仅 HAVING 引用的),统一以 exprKey 与输出键写入
|
// 先算聚合(含仅 HAVING 引用的),统一以 exprKey 与输出键写入
|
||||||
@@ -14518,7 +14629,9 @@ class QueryExecutor {
|
|||||||
output[aliasMatch[2]] = aggregated[aliasMatch[2]];
|
output[aliasMatch[2]] = aggregated[aliasMatch[2]];
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const base = bareReference(colExpr);
|
// v0.8.0(B-5):脱引号 —— `SELECT "1" ... GROUP BY "1"` 的输出键必须与
|
||||||
|
// 行里的真实列名一致,否则会同时出现 `1` 与 `"1"` 两个键(实测)。
|
||||||
|
const base = unquoteIdentifier(bareReference(colExpr));
|
||||||
output[base] = base in aggregated ? aggregated[base] : aggregated[colExpr];
|
output[base] = base in aggregated ? aggregated[base] : aggregated[colExpr];
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
@@ -15178,7 +15291,10 @@ class QueryExecutor {
|
|||||||
const missing = [];
|
const missing = [];
|
||||||
const ambiguous = [];
|
const ambiguous = [];
|
||||||
const check = (ref) => {
|
const check = (ref) => {
|
||||||
const text = ref.trim();
|
// v0.8.0(B-5):校验用**真实列名** —— 分隔标识符(`"1"`)在 WHERE 里
|
||||||
|
// 同样是列引用,必须脱引号后再比对(此前会报 `Unknown column "\"1\""`
|
||||||
|
// —— 错误消息里带着引号,用户看不出问题在哪)。
|
||||||
|
const text = unquoteIdentifier(ref);
|
||||||
if (!text)
|
if (!text)
|
||||||
return;
|
return;
|
||||||
if (available.has(text)) {
|
if (available.has(text)) {
|
||||||
@@ -15324,6 +15440,9 @@ class QueryExecutor {
|
|||||||
const aliasMatch = col.match(/^(.+?)\s+AS\s+\w+$/i);
|
const aliasMatch = col.match(/^(.+?)\s+AS\s+\w+$/i);
|
||||||
if (aliasMatch)
|
if (aliasMatch)
|
||||||
reference = aliasMatch[1].trim();
|
reference = aliasMatch[1].trim();
|
||||||
|
// v0.8.0(B-5):校验用的是**真实列名**,因此这里脱去分隔标识符的引号
|
||||||
|
//(`"1"` → `1`);保留引号只在投影期用于区分"列 vs 常量"。
|
||||||
|
reference = unquoteIdentifier(reference);
|
||||||
// 常量列(字符串 / 数字 / 布尔 / NULL)
|
// 常量列(字符串 / 数字 / 布尔 / NULL)
|
||||||
if (/^'.*'$/s.test(reference))
|
if (/^'.*'$/s.test(reference))
|
||||||
continue;
|
continue;
|
||||||
@@ -15392,6 +15511,15 @@ class QueryExecutor {
|
|||||||
constCols.push({ key: col, value: resolveAliasSource(col, row) });
|
constCols.push({ key: col, value: resolveAliasSource(col, row) });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// v0.8.0(B-5):分隔标识符(`"1"`)是**列引用**,不是常量 —— 在这里脱引号
|
||||||
|
// 落到 plain 分支走正常列投影(输出键即裸列名,与 `SELECT *` 一致)。
|
||||||
|
// 脱引号必须在本函数内完成:更早脱会让 `"1"` 被上面的裸数字常量子句吃掉
|
||||||
|
// (实测 `SELECT "1" FROM q` 返回 `{"1":1}` —— 常量 1 而非列值)。
|
||||||
|
const unquoted = unquoteIdentifier(col);
|
||||||
|
if (unquoted !== col) {
|
||||||
|
plain.push(unquoted);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
plain.push(col);
|
plain.push(col);
|
||||||
}
|
}
|
||||||
// v0.7.3: hasStar 时以原行全部列为基(projectColumns 仅投影 plain 列,不含 * 的其余列)
|
// v0.7.3: hasStar 时以原行全部列为基(projectColumns 仅投影 plain 列,不含 * 的其余列)
|
||||||
@@ -15450,6 +15578,111 @@ class QueryExecutor {
|
|||||||
// ===================================================================
|
// ===================================================================
|
||||||
// 关联子查询 / 别名规范化(v0.3.0)
|
// 关联子查询 / 别名规范化(v0.3.0)
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-5):把 ORDER BY / GROUP BY 里的**输出列序号**解析为输出列名。
|
||||||
|
*
|
||||||
|
* SQL 标准允许 `ORDER BY 1` / `GROUP BY 2` 按输出列位置引用(UNION 各分支
|
||||||
|
* 列名可能不同,只能按序号引用)。规则:
|
||||||
|
* 1. 若存在**同名的真实列**(如列名就是 `"1"`,用双引号建表),按列名优先 ——
|
||||||
|
* 显式标识符胜过位置简写;
|
||||||
|
* 2. 纯数字 → 第 N 个输出列的**表达式原文**(`SELECT n AS num ... ORDER BY 1`
|
||||||
|
* 解析为 `num`,因为投影后行里只有 `num`);
|
||||||
|
* 3. 序号越界 → `QUERY_ERROR`(不说"未知列",因为问题出在位置而不是名字);
|
||||||
|
* 4. `GROUP BY <序号>` 指向聚合表达式 → `QUERY_ERROR`
|
||||||
|
* (按聚合值分组语义上不成立,SQL 标准同样禁止)。
|
||||||
|
*/
|
||||||
|
async resolveOutputOrdinals(stmt) {
|
||||||
|
const outputs = stmt.columns;
|
||||||
|
// 真实列名集合(用于优先级判定)—— 只在确有"纯数字键"时才去取 schema
|
||||||
|
let realColumns = null;
|
||||||
|
const loadRealColumns = async () => {
|
||||||
|
if (realColumns)
|
||||||
|
return realColumns;
|
||||||
|
const names = new Set();
|
||||||
|
const addTable = async (table) => {
|
||||||
|
const schema = await this.engine.getTableSchema(table);
|
||||||
|
if (!schema)
|
||||||
|
return;
|
||||||
|
for (const col of Object.keys(schema.columns))
|
||||||
|
names.add(col);
|
||||||
|
};
|
||||||
|
if (stmt.from)
|
||||||
|
await addTable(stmt.from);
|
||||||
|
for (const join of stmt.joins ?? [])
|
||||||
|
await addTable(join.table);
|
||||||
|
realColumns = names;
|
||||||
|
return names;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 判断一个纯数字键是否应当**按列名**解释(而不是按输出列位置)。
|
||||||
|
*
|
||||||
|
* 规则(与 SQLite / SQL 标准的名称解析一致):
|
||||||
|
* 1. SELECT 列表里有同名的**输出列**(裸名或别名)→ 列名引用;
|
||||||
|
* 2. 行源里存在同名**真实列**,且该列在 SELECT 列表里以**未加引号的同名**
|
||||||
|
* 形式出现 → 列名引用;
|
||||||
|
* 3. 其余情况(含"行源有名为 `"1"` 的列,但查询里写的是裸 `1`")→ 位置序号。
|
||||||
|
*
|
||||||
|
* 第 3 条是关键:`"1"`(引号标识符)与 `1`(数字字面量)在 SQL 里是**不同的
|
||||||
|
* 东西**。此前把未加引号的 `1` 拿去 schema 里查"是否存在列 1",于是
|
||||||
|
* `SELECT other FROM q ORDER BY 1 DESC` 因 q 恰好有名为 `"1"` 的列而被当成
|
||||||
|
* 普通列排序 —— **DESC 丢失**(实测)。想按那一列排序必须写 `ORDER BY "1"`。
|
||||||
|
*/
|
||||||
|
const hasRealColumn = async (key) => {
|
||||||
|
const trimmed = key.trim();
|
||||||
|
if (outputs.some((o) => bareReference(o) === trimmed || new RegExp(`\\bAS\\s+${trimmed}$`, 'i').test(o))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const columns = await loadRealColumns();
|
||||||
|
if (!columns.has(trimmed))
|
||||||
|
return false;
|
||||||
|
// 只有"查询里以裸名形式引用了该列"才算列名引用
|
||||||
|
return outputs.some((o) => o === trimmed);
|
||||||
|
};
|
||||||
|
/** 序号 → 输出列名(投影后行里的键) */
|
||||||
|
const outputKeyAt = (position, context) => {
|
||||||
|
if (position < 1 || position > outputs.length) {
|
||||||
|
throw new DatabaseError(`${context} position ${position} is out of range: query has ${outputs.length} output column(s)`, 'QUERY_ERROR', { position, outputs: outputs.length });
|
||||||
|
}
|
||||||
|
const raw = outputs[position - 1];
|
||||||
|
const caseExpr = parseCaseExpression(raw);
|
||||||
|
if (caseExpr)
|
||||||
|
return caseExpr.alias ?? raw;
|
||||||
|
const agg = parseAggregateExpression(raw);
|
||||||
|
if (agg)
|
||||||
|
return agg.outputKey;
|
||||||
|
const aliasMatch = raw.match(/^(.+?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
|
||||||
|
if (aliasMatch)
|
||||||
|
return aliasMatch[2];
|
||||||
|
return bareReference(raw);
|
||||||
|
};
|
||||||
|
if (stmt.orderBy) {
|
||||||
|
const resolved = [];
|
||||||
|
for (const item of stmt.orderBy) {
|
||||||
|
if (!/^\d+$/.test(item.column.trim()) || await hasRealColumn(item.column)) {
|
||||||
|
resolved.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
resolved.push({ ...item, column: outputKeyAt(Number(item.column.trim()), 'ORDER BY') });
|
||||||
|
}
|
||||||
|
stmt.orderBy = resolved;
|
||||||
|
}
|
||||||
|
if (stmt.groupBy) {
|
||||||
|
const resolved = [];
|
||||||
|
for (const key of stmt.groupBy) {
|
||||||
|
if (!/^\d+$/.test(key.trim()) || await hasRealColumn(key)) {
|
||||||
|
resolved.push(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const position = Number(key.trim());
|
||||||
|
const raw = outputs[position - 1];
|
||||||
|
if (raw !== undefined && parseAggregateExpression(raw)) {
|
||||||
|
throw new DatabaseError(`GROUP BY position ${position} refers to an aggregate expression ("${raw.trim()}")`, 'QUERY_ERROR', { position });
|
||||||
|
}
|
||||||
|
resolved.push(outputKeyAt(position, 'GROUP BY'));
|
||||||
|
}
|
||||||
|
stmt.groupBy = resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* 归一化"行键不带前缀"的查询中的所有引用 —— 剥离表别名前缀。
|
* 归一化"行键不带前缀"的查询中的所有引用 —— 剥离表别名前缀。
|
||||||
*
|
*
|
||||||
@@ -15468,11 +15701,18 @@ class QueryExecutor {
|
|||||||
if (stmt.where && Object.keys(stmt.where).length > 0) {
|
if (stmt.where && Object.keys(stmt.where).length > 0) {
|
||||||
stmt.where = this.normalizeWhereColumns(stmt.where, list);
|
stmt.where = this.normalizeWhereColumns(stmt.where, list);
|
||||||
}
|
}
|
||||||
|
// v0.8.0(B-5):ORDER BY / GROUP BY 的键也要脱引号 —— 与 WHERE 同一口径。
|
||||||
|
// 保留引号的唯一目的是让投影期区分"名为 1 的列(`"1"`)"与"常量 1(`1`)";
|
||||||
|
// 排序/分组阶段必须用**真实列名**,否则 `ORDER BY "1"` 取不到列值而静默
|
||||||
|
// 按原序返回、`GROUP BY "1"` 会在输出行里留下 `"1"` 与 `1` 两个键(均已实测)。
|
||||||
if (stmt.orderBy && stmt.orderBy.length > 0) {
|
if (stmt.orderBy && stmt.orderBy.length > 0) {
|
||||||
stmt.orderBy = stmt.orderBy.map((o) => ({ ...o, column: this.stripAlias(o.column, list) }));
|
stmt.orderBy = stmt.orderBy.map((o) => ({
|
||||||
|
...o,
|
||||||
|
column: unquoteIdentifier(this.stripAlias(o.column, list)),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
if (stmt.groupBy && stmt.groupBy.length > 0) {
|
if (stmt.groupBy && stmt.groupBy.length > 0) {
|
||||||
stmt.groupBy = stmt.groupBy.map((c) => this.stripAlias(c, list));
|
stmt.groupBy = stmt.groupBy.map((c) => unquoteIdentifier(this.stripAlias(c, list)));
|
||||||
}
|
}
|
||||||
stmt.columns = stmt.columns.map((c) => {
|
stmt.columns = stmt.columns.map((c) => {
|
||||||
if (c === '*' || isAggregateExpression(c) || /^\s*CASE\b/i.test(c) || /^'/.test(c))
|
if (c === '*' || isAggregateExpression(c) || /^\s*CASE\b/i.test(c) || /^'/.test(c))
|
||||||
@@ -15501,7 +15741,10 @@ class QueryExecutor {
|
|||||||
normalized[key] = this.normalizeExistsValue(value, aliases);
|
normalized[key] = this.normalizeExistsValue(value, aliases);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const newKey = this.stripAlias(key, aliases);
|
// v0.8.0(B-5):WHERE 键脱去分隔标识符引号(`"1" = 'y'` → `1`),
|
||||||
|
// 否则 matchWhere 用裸 `1` 取值、而行键也是裸 `1`,两者因为引号不同
|
||||||
|
// 永远匹配不上 → 静默空结果(实测)。引号到此已完成"区分列 vs 常量"的使命。
|
||||||
|
const newKey = unquoteIdentifier(this.stripAlias(key, aliases));
|
||||||
normalized[newKey] = this.normalizeFieldValue(value, aliases);
|
normalized[newKey] = this.normalizeFieldValue(value, aliases);
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -15530,11 +15773,11 @@ class QueryExecutor {
|
|||||||
ops[op] = this.normalizeFieldValue(operand, aliases);
|
ops[op] = this.normalizeFieldValue(operand, aliases);
|
||||||
}
|
}
|
||||||
else if (op === '$col') {
|
else if (op === '$col') {
|
||||||
ops[op] = this.stripAlias(String(operand), aliases);
|
ops[op] = unquoteIdentifier(this.stripAlias(String(operand), aliases));
|
||||||
}
|
}
|
||||||
else if (typeof operand === 'object' && operand !== null && !Array.isArray(operand) && '$col' in operand) {
|
else if (typeof operand === 'object' && operand !== null && !Array.isArray(operand) && '$col' in operand) {
|
||||||
// 操作符值中嵌套的列引用:{ $eq: { $col: 'u.id' } }
|
// 操作符值中嵌套的列引用:{ $eq: { $col: 'u.id' } }
|
||||||
ops[op] = { $col: this.stripAlias(String(operand.$col), aliases) };
|
ops[op] = { $col: unquoteIdentifier(this.stripAlias(String(operand.$col), aliases)) };
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
ops[op] = operand;
|
ops[op] = operand;
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+14
@@ -856,6 +856,20 @@ declare class QueryExecutor {
|
|||||||
* 于是 `COUNT (n)`(函数名后有空格)在"是否聚合"判定与"如何求值"两处结论不同。
|
* 于是 `COUNT (n)`(函数名后有空格)在"是否聚合"判定与"如何求值"两处结论不同。
|
||||||
*/
|
*/
|
||||||
private computeSingleAggregate;
|
private computeSingleAggregate;
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-5):把 ORDER BY / GROUP BY 里的**输出列序号**解析为输出列名。
|
||||||
|
*
|
||||||
|
* SQL 标准允许 `ORDER BY 1` / `GROUP BY 2` 按输出列位置引用(UNION 各分支
|
||||||
|
* 列名可能不同,只能按序号引用)。规则:
|
||||||
|
* 1. 若存在**同名的真实列**(如列名就是 `"1"`,用双引号建表),按列名优先 ——
|
||||||
|
* 显式标识符胜过位置简写;
|
||||||
|
* 2. 纯数字 → 第 N 个输出列的**表达式原文**(`SELECT n AS num ... ORDER BY 1`
|
||||||
|
* 解析为 `num`,因为投影后行里只有 `num`);
|
||||||
|
* 3. 序号越界 → `QUERY_ERROR`(不说"未知列",因为问题出在位置而不是名字);
|
||||||
|
* 4. `GROUP BY <序号>` 指向聚合表达式 → `QUERY_ERROR`
|
||||||
|
* (按聚合值分组语义上不成立,SQL 标准同样禁止)。
|
||||||
|
*/
|
||||||
|
private resolveOutputOrdinals;
|
||||||
/**
|
/**
|
||||||
* 归一化"行键不带前缀"的查询中的所有引用 —— 剥离表别名前缀。
|
* 归一化"行键不带前缀"的查询中的所有引用 —— 剥离表别名前缀。
|
||||||
*
|
*
|
||||||
|
|||||||
Vendored
+262
-19
@@ -1004,23 +1004,49 @@ function compare(a, b) {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 列投影
|
// 列投影
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 列投影。
|
||||||
|
*
|
||||||
|
* v0.8.0(B-5):支持**分隔标识符**(`"1"`)与 JOIN 行的 `别名.列` 键。
|
||||||
|
* 此前只做"精确命中,否则找 `endsWith('.col')`",于是:
|
||||||
|
* - `SELECT "1" FROM q`(列名就叫 1)取不到值 → 输出 `{}`
|
||||||
|
* (校验用裸名、投影用带引号的名,两套规则 —— 典型漂移);
|
||||||
|
* - 找不到时**不产出键**,行形状随列是否存在而变。
|
||||||
|
* 现在统一:脱引号 → 精确命中 → 唯一后缀命中;仍找不到则不产出该键
|
||||||
|
*(是否"未知列"由 executor 的 assertProjectionColumnsExist 判定并报错,
|
||||||
|
* 投影层不做静默兜底)。
|
||||||
|
*/
|
||||||
function projectColumns(row, columns) {
|
function projectColumns(row, columns) {
|
||||||
const projected = {};
|
const projected = {};
|
||||||
for (const col of columns) {
|
for (const col of columns) {
|
||||||
if (col in row) {
|
const name = unquoteIdentifier$1(col);
|
||||||
projected[col] = row[col];
|
if (name in row) {
|
||||||
|
projected[name] = row[name];
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
else {
|
// JOIN 行键形如 `t.col`:唯一后缀匹配(多个命中视为歧义,取第一个与
|
||||||
|
// executor 的校验口径一致 —— 那里已对歧义报错,能走到这里说明唯一)
|
||||||
|
let found;
|
||||||
|
let hits = 0;
|
||||||
for (const key of Object.keys(row)) {
|
for (const key of Object.keys(row)) {
|
||||||
if (key.endsWith(`.${col}`) || key === col) {
|
if (key.endsWith(`.${name}`) || key === name) {
|
||||||
projected[col] = row[key];
|
found = row[key];
|
||||||
break;
|
hits += 1;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (hits >= 1)
|
||||||
|
projected[name] = found;
|
||||||
}
|
}
|
||||||
return projected;
|
return projected;
|
||||||
}
|
}
|
||||||
|
/** 脱去分隔标识符的引号(`"1"` → `1`,`"a""b"` → `a"b`) */
|
||||||
|
function unquoteIdentifier$1(text) {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||||
|
return trimmed.slice(1, -1).replace(/""/g, '"');
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* metona-sqlark 统一行校验 —— 存储写入的**唯一**验证与规范化入口(v0.8.0)
|
* metona-sqlark 统一行校验 —— 存储写入的**唯一**验证与规范化入口(v0.8.0)
|
||||||
@@ -11921,7 +11947,7 @@ class Parser {
|
|||||||
if (this.curTokenIs(TokenType.GROUP)) {
|
if (this.curTokenIs(TokenType.GROUP)) {
|
||||||
this.nextToken();
|
this.nextToken();
|
||||||
this.expect(TokenType.BY);
|
this.expect(TokenType.BY);
|
||||||
stmt.groupBy = this.parseIdentifierList();
|
stmt.groupBy = this.parseGroupByList();
|
||||||
}
|
}
|
||||||
// HAVING(可选)
|
// HAVING(可选)
|
||||||
if (this.curTokenIs(TokenType.HAVING)) {
|
if (this.curTokenIs(TokenType.HAVING)) {
|
||||||
@@ -12713,6 +12739,24 @@ class Parser {
|
|||||||
this.curTokenIs(TokenType.MAX)) {
|
this.curTokenIs(TokenType.MAX)) {
|
||||||
return this.parseAggregateCall();
|
return this.parseAggregateCall();
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-5):分隔标识符必须**保留引号**。
|
||||||
|
*
|
||||||
|
* 此前 `SELECT "1" FROM q`(列名就叫 `1`,建表时用引号声明)被解析成裸 `1`,
|
||||||
|
* 而投影阶段把裸数字当**常量**列 → 返回 `{"1": 1}`(字面量 1),
|
||||||
|
* 而 `SELECT *` 返回正确的 `{"1": "z"}` —— 同一列两种结论(实测)。
|
||||||
|
* 保留引号后,下游能区分"名为 1 的列"与"常量 1"。
|
||||||
|
*/
|
||||||
|
if (this.curTokenIs(TokenType.QUOTED_IDENTIFIER)) {
|
||||||
|
const name = this.curToken.value;
|
||||||
|
this.nextToken();
|
||||||
|
if (this.curTokenIs(TokenType.DOT)) {
|
||||||
|
this.nextToken();
|
||||||
|
const second = this.expectIdentifier('column name after "."');
|
||||||
|
return `"${name.replace(/"/g, '""')}".${second}`;
|
||||||
|
}
|
||||||
|
return `"${name.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
const first = this.expectIdentifier('column name');
|
const first = this.expectIdentifier('column name');
|
||||||
if (this.curTokenIs(TokenType.DOT)) {
|
if (this.curTokenIs(TokenType.DOT)) {
|
||||||
this.nextToken();
|
this.nextToken();
|
||||||
@@ -12812,6 +12856,40 @@ class Parser {
|
|||||||
}
|
}
|
||||||
return vals;
|
return vals;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-5):ORDER BY / GROUP BY 的键可以是**输出列序号**(SQL 标准)。
|
||||||
|
*
|
||||||
|
* `ORDER BY 1` 表示"按第 1 个输出列排序",`GROUP BY 2` 同理 ——
|
||||||
|
* 这在手写 SQL 与 UNION 里非常常用(各分支输出列名可能不同,只能按序号引用)。
|
||||||
|
*
|
||||||
|
* 此前 parser 的 `parseIdentifierWithDot` 只接受标识符,于是 `ORDER BY 1`
|
||||||
|
* 直接 `PARSE_ERROR: Expected identifier, got "1"`(实测)。
|
||||||
|
*
|
||||||
|
* 这里把序号**原样保留为数字字符串**(AST 形状不变),由 executor 在拿到
|
||||||
|
* SELECT 列表后再解析成对应表达式 —— 序号的含义依赖 SELECT 列表,
|
||||||
|
* parser 层无从判断。
|
||||||
|
*/
|
||||||
|
parseOrderOrGroupKey() {
|
||||||
|
if (this.curTokenIs(TokenType.NUMBER)) {
|
||||||
|
const value = this.curToken.value;
|
||||||
|
// 序号必须是正整数(`ORDER BY 0` / `ORDER BY 1.5` 非法)
|
||||||
|
if (!/^\d+$/.test(value) || Number(value) < 1) {
|
||||||
|
throw this.error(`Invalid output column ordinal "${value}" (must be a positive integer)`);
|
||||||
|
}
|
||||||
|
this.nextToken();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return this.parseIdentifierWithDot();
|
||||||
|
}
|
||||||
|
parseGroupByList() {
|
||||||
|
const list = [];
|
||||||
|
list.push(this.parseOrderOrGroupKey());
|
||||||
|
while (this.curTokenIs(TokenType.COMMA)) {
|
||||||
|
this.nextToken();
|
||||||
|
list.push(this.parseOrderOrGroupKey());
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
parseOrderByList() {
|
parseOrderByList() {
|
||||||
const list = [];
|
const list = [];
|
||||||
list.push(this.parseOrderBy());
|
list.push(this.parseOrderBy());
|
||||||
@@ -12822,7 +12900,7 @@ class Parser {
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
parseOrderBy() {
|
parseOrderBy() {
|
||||||
const column = this.parseIdentifierWithDot();
|
const column = this.parseOrderOrGroupKey();
|
||||||
let direction = 'asc';
|
let direction = 'asc';
|
||||||
if (this.curTokenIs(TokenType.ASC)) {
|
if (this.curTokenIs(TokenType.ASC)) {
|
||||||
this.nextToken();
|
this.nextToken();
|
||||||
@@ -13003,7 +13081,13 @@ function parseWhereCondition(sql) {
|
|||||||
* 静默取第一个正是"结果取决于表顺序"这类难查问题的来源。
|
* 静默取第一个正是"结果取决于表顺序"这类难查问题的来源。
|
||||||
*/
|
*/
|
||||||
function resolveColumnValue(row, reference, opts) {
|
function resolveColumnValue(row, reference, opts) {
|
||||||
const text = reference.trim();
|
let text = reference.trim();
|
||||||
|
// v0.8.0:分隔标识符(`"col"`)在比较/取值时要脱去引号 ——
|
||||||
|
// 保留引号是为了让投影阶段能区分"名为 1 的列"与"常量 1"(见 parser 说明),
|
||||||
|
// 但比较/取值必须用真实列名。`""` 是引号自身的转义。
|
||||||
|
if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) {
|
||||||
|
text = text.slice(1, -1).replace(/""/g, '"');
|
||||||
|
}
|
||||||
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` 作键,单表路径的键不带前缀
|
||||||
@@ -13568,7 +13652,29 @@ function resolveGroupKeyValue(row, item) {
|
|||||||
const caseExpr = parseCaseExpression(item);
|
const caseExpr = parseCaseExpression(item);
|
||||||
if (caseExpr)
|
if (caseExpr)
|
||||||
return evaluateCase(caseExpr, row);
|
return evaluateCase(caseExpr, row);
|
||||||
return resolveColumnValue(row, item, { strict: true, context: 'GROUP BY' });
|
// 脱引号后再取值:解析层保留引号是为了区分"列 vs 常量",执行期必须用真实列名
|
||||||
|
//(否则 `GROUP BY "1"` 会在行里产出重复的 `1` 与 `"1"` 两个键 —— 实测)
|
||||||
|
return resolveColumnValue(row, unquoteIdentifier(item), { strict: true, context: 'GROUP BY' });
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-5):脱去分隔标识符的引号 —— `"1"` → `1`,`"a""b"` → `a"b`。
|
||||||
|
*
|
||||||
|
* 为什么 parser 保留引号、这里再脱:parser 必须保留才能区分
|
||||||
|
* "名为 1 的列"(`"1"`)与"常量 1"(`1`);而一旦进入执行期,列名就是
|
||||||
|
* schema 里的裸名字。**统一在这一个边界脱引号**,避免"投影用带引号的名字、
|
||||||
|
* 取值用裸名字"这类两套规则(那正是本项目反复出现的缺陷模式)。
|
||||||
|
*/
|
||||||
|
function unquoteIdentifier(text) {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||||
|
return trimmed.slice(1, -1).replace(/""/g, '"');
|
||||||
|
}
|
||||||
|
// `"alias"."col"` 形态:两侧都脱引号
|
||||||
|
if (trimmed.includes('.')) {
|
||||||
|
const parts = trimmed.split('.');
|
||||||
|
return parts.map((part) => unquoteIdentifier(part)).join('.');
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
}
|
}
|
||||||
function resolveAliasSource(source, row) {
|
function resolveAliasSource(source, row) {
|
||||||
const text = source.trim();
|
const text = source.trim();
|
||||||
@@ -13973,6 +14079,7 @@ class QueryExecutor {
|
|||||||
// 此前这里**不做**归一化:`SELECT d.id FROM (SELECT id, g FROM t) AS d`
|
// 此前这里**不做**归一化:`SELECT d.id FROM (SELECT id, g FROM t) AS d`
|
||||||
// 直接拿 `d.id` 去投影,行里只有 `id` → 静默返回 `[]`;
|
// 直接拿 `d.id` 去投影,行里只有 `id` → 静默返回 `[]`;
|
||||||
// 而同一查询写成 `SELECT id ...` 却正确 —— 同一行源两种写法结论相反。
|
// 而同一查询写成 `SELECT id ...` 却正确 —— 同一行源两种写法结论相反。
|
||||||
|
await this.resolveOutputOrdinals(stmt);
|
||||||
this.normalizeUnprefixedReferences(stmt, [stmt.alias ?? stmt.from]);
|
this.normalizeUnprefixedReferences(stmt, [stmt.alias ?? stmt.from]);
|
||||||
if (stmt.where && Object.keys(stmt.where).length > 0) {
|
if (stmt.where && Object.keys(stmt.where).length > 0) {
|
||||||
// 非 JOIN:WHERE 在 executor 端过滤(子查询结果不经引擎)
|
// 非 JOIN:WHERE 在 executor 端过滤(子查询结果不经引擎)
|
||||||
@@ -13990,6 +14097,10 @@ class QueryExecutor {
|
|||||||
rows = await this.executeJoinSelect(stmt);
|
rows = await this.executeJoinSelect(stmt);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
// v0.8.0(B-5):先把 ORDER BY / GROUP BY 的输出列序号解析为输出列名。
|
||||||
|
// 必须在归一化之前:序号 → 列名的映射依赖 SELECT 列表原文
|
||||||
|
//(`SELECT u.n FROM t u ORDER BY 1` → `u.n`,归一化后再解析会丢前缀)。
|
||||||
|
await this.resolveOutputOrdinals(stmt);
|
||||||
// 非 JOIN 路径:行键不带别名前缀 → 统一归一化引用
|
// 非 JOIN 路径:行键不带别名前缀 → 统一归一化引用
|
||||||
const mainAliases = [stmt.alias ?? stmt.from].filter(Boolean);
|
const mainAliases = [stmt.alias ?? stmt.from].filter(Boolean);
|
||||||
this.normalizeUnprefixedReferences(stmt, mainAliases);
|
this.normalizeUnprefixedReferences(stmt, mainAliases);
|
||||||
@@ -14414,7 +14525,7 @@ class QueryExecutor {
|
|||||||
// → 用**别名** `band`,因为 projectGroupedRow 按别名索引;
|
// → 用**别名** `band`,因为 projectGroupedRow 按别名索引;
|
||||||
// - 裸列 → 用列名。
|
// - 裸列 → 用列名。
|
||||||
const caseExpr = parseCaseExpression(col);
|
const caseExpr = parseCaseExpression(col);
|
||||||
const key = caseExpr?.alias ?? col;
|
const key = caseExpr?.alias ?? unquoteIdentifier(col);
|
||||||
aggregated[key] = resolveGroupKeyValue(first, col);
|
aggregated[key] = resolveGroupKeyValue(first, col);
|
||||||
}
|
}
|
||||||
// 先算聚合(含仅 HAVING 引用的),统一以 exprKey 与输出键写入
|
// 先算聚合(含仅 HAVING 引用的),统一以 exprKey 与输出键写入
|
||||||
@@ -14514,7 +14625,9 @@ class QueryExecutor {
|
|||||||
output[aliasMatch[2]] = aggregated[aliasMatch[2]];
|
output[aliasMatch[2]] = aggregated[aliasMatch[2]];
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const base = bareReference(colExpr);
|
// v0.8.0(B-5):脱引号 —— `SELECT "1" ... GROUP BY "1"` 的输出键必须与
|
||||||
|
// 行里的真实列名一致,否则会同时出现 `1` 与 `"1"` 两个键(实测)。
|
||||||
|
const base = unquoteIdentifier(bareReference(colExpr));
|
||||||
output[base] = base in aggregated ? aggregated[base] : aggregated[colExpr];
|
output[base] = base in aggregated ? aggregated[base] : aggregated[colExpr];
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
@@ -15174,7 +15287,10 @@ class QueryExecutor {
|
|||||||
const missing = [];
|
const missing = [];
|
||||||
const ambiguous = [];
|
const ambiguous = [];
|
||||||
const check = (ref) => {
|
const check = (ref) => {
|
||||||
const text = ref.trim();
|
// v0.8.0(B-5):校验用**真实列名** —— 分隔标识符(`"1"`)在 WHERE 里
|
||||||
|
// 同样是列引用,必须脱引号后再比对(此前会报 `Unknown column "\"1\""`
|
||||||
|
// —— 错误消息里带着引号,用户看不出问题在哪)。
|
||||||
|
const text = unquoteIdentifier(ref);
|
||||||
if (!text)
|
if (!text)
|
||||||
return;
|
return;
|
||||||
if (available.has(text)) {
|
if (available.has(text)) {
|
||||||
@@ -15320,6 +15436,9 @@ class QueryExecutor {
|
|||||||
const aliasMatch = col.match(/^(.+?)\s+AS\s+\w+$/i);
|
const aliasMatch = col.match(/^(.+?)\s+AS\s+\w+$/i);
|
||||||
if (aliasMatch)
|
if (aliasMatch)
|
||||||
reference = aliasMatch[1].trim();
|
reference = aliasMatch[1].trim();
|
||||||
|
// v0.8.0(B-5):校验用的是**真实列名**,因此这里脱去分隔标识符的引号
|
||||||
|
//(`"1"` → `1`);保留引号只在投影期用于区分"列 vs 常量"。
|
||||||
|
reference = unquoteIdentifier(reference);
|
||||||
// 常量列(字符串 / 数字 / 布尔 / NULL)
|
// 常量列(字符串 / 数字 / 布尔 / NULL)
|
||||||
if (/^'.*'$/s.test(reference))
|
if (/^'.*'$/s.test(reference))
|
||||||
continue;
|
continue;
|
||||||
@@ -15388,6 +15507,15 @@ class QueryExecutor {
|
|||||||
constCols.push({ key: col, value: resolveAliasSource(col, row) });
|
constCols.push({ key: col, value: resolveAliasSource(col, row) });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// v0.8.0(B-5):分隔标识符(`"1"`)是**列引用**,不是常量 —— 在这里脱引号
|
||||||
|
// 落到 plain 分支走正常列投影(输出键即裸列名,与 `SELECT *` 一致)。
|
||||||
|
// 脱引号必须在本函数内完成:更早脱会让 `"1"` 被上面的裸数字常量子句吃掉
|
||||||
|
// (实测 `SELECT "1" FROM q` 返回 `{"1":1}` —— 常量 1 而非列值)。
|
||||||
|
const unquoted = unquoteIdentifier(col);
|
||||||
|
if (unquoted !== col) {
|
||||||
|
plain.push(unquoted);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
plain.push(col);
|
plain.push(col);
|
||||||
}
|
}
|
||||||
// v0.7.3: hasStar 时以原行全部列为基(projectColumns 仅投影 plain 列,不含 * 的其余列)
|
// v0.7.3: hasStar 时以原行全部列为基(projectColumns 仅投影 plain 列,不含 * 的其余列)
|
||||||
@@ -15446,6 +15574,111 @@ class QueryExecutor {
|
|||||||
// ===================================================================
|
// ===================================================================
|
||||||
// 关联子查询 / 别名规范化(v0.3.0)
|
// 关联子查询 / 别名规范化(v0.3.0)
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-5):把 ORDER BY / GROUP BY 里的**输出列序号**解析为输出列名。
|
||||||
|
*
|
||||||
|
* SQL 标准允许 `ORDER BY 1` / `GROUP BY 2` 按输出列位置引用(UNION 各分支
|
||||||
|
* 列名可能不同,只能按序号引用)。规则:
|
||||||
|
* 1. 若存在**同名的真实列**(如列名就是 `"1"`,用双引号建表),按列名优先 ——
|
||||||
|
* 显式标识符胜过位置简写;
|
||||||
|
* 2. 纯数字 → 第 N 个输出列的**表达式原文**(`SELECT n AS num ... ORDER BY 1`
|
||||||
|
* 解析为 `num`,因为投影后行里只有 `num`);
|
||||||
|
* 3. 序号越界 → `QUERY_ERROR`(不说"未知列",因为问题出在位置而不是名字);
|
||||||
|
* 4. `GROUP BY <序号>` 指向聚合表达式 → `QUERY_ERROR`
|
||||||
|
* (按聚合值分组语义上不成立,SQL 标准同样禁止)。
|
||||||
|
*/
|
||||||
|
async resolveOutputOrdinals(stmt) {
|
||||||
|
const outputs = stmt.columns;
|
||||||
|
// 真实列名集合(用于优先级判定)—— 只在确有"纯数字键"时才去取 schema
|
||||||
|
let realColumns = null;
|
||||||
|
const loadRealColumns = async () => {
|
||||||
|
if (realColumns)
|
||||||
|
return realColumns;
|
||||||
|
const names = new Set();
|
||||||
|
const addTable = async (table) => {
|
||||||
|
const schema = await this.engine.getTableSchema(table);
|
||||||
|
if (!schema)
|
||||||
|
return;
|
||||||
|
for (const col of Object.keys(schema.columns))
|
||||||
|
names.add(col);
|
||||||
|
};
|
||||||
|
if (stmt.from)
|
||||||
|
await addTable(stmt.from);
|
||||||
|
for (const join of stmt.joins ?? [])
|
||||||
|
await addTable(join.table);
|
||||||
|
realColumns = names;
|
||||||
|
return names;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 判断一个纯数字键是否应当**按列名**解释(而不是按输出列位置)。
|
||||||
|
*
|
||||||
|
* 规则(与 SQLite / SQL 标准的名称解析一致):
|
||||||
|
* 1. SELECT 列表里有同名的**输出列**(裸名或别名)→ 列名引用;
|
||||||
|
* 2. 行源里存在同名**真实列**,且该列在 SELECT 列表里以**未加引号的同名**
|
||||||
|
* 形式出现 → 列名引用;
|
||||||
|
* 3. 其余情况(含"行源有名为 `"1"` 的列,但查询里写的是裸 `1`")→ 位置序号。
|
||||||
|
*
|
||||||
|
* 第 3 条是关键:`"1"`(引号标识符)与 `1`(数字字面量)在 SQL 里是**不同的
|
||||||
|
* 东西**。此前把未加引号的 `1` 拿去 schema 里查"是否存在列 1",于是
|
||||||
|
* `SELECT other FROM q ORDER BY 1 DESC` 因 q 恰好有名为 `"1"` 的列而被当成
|
||||||
|
* 普通列排序 —— **DESC 丢失**(实测)。想按那一列排序必须写 `ORDER BY "1"`。
|
||||||
|
*/
|
||||||
|
const hasRealColumn = async (key) => {
|
||||||
|
const trimmed = key.trim();
|
||||||
|
if (outputs.some((o) => bareReference(o) === trimmed || new RegExp(`\\bAS\\s+${trimmed}$`, 'i').test(o))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const columns = await loadRealColumns();
|
||||||
|
if (!columns.has(trimmed))
|
||||||
|
return false;
|
||||||
|
// 只有"查询里以裸名形式引用了该列"才算列名引用
|
||||||
|
return outputs.some((o) => o === trimmed);
|
||||||
|
};
|
||||||
|
/** 序号 → 输出列名(投影后行里的键) */
|
||||||
|
const outputKeyAt = (position, context) => {
|
||||||
|
if (position < 1 || position > outputs.length) {
|
||||||
|
throw new DatabaseError(`${context} position ${position} is out of range: query has ${outputs.length} output column(s)`, 'QUERY_ERROR', { position, outputs: outputs.length });
|
||||||
|
}
|
||||||
|
const raw = outputs[position - 1];
|
||||||
|
const caseExpr = parseCaseExpression(raw);
|
||||||
|
if (caseExpr)
|
||||||
|
return caseExpr.alias ?? raw;
|
||||||
|
const agg = parseAggregateExpression(raw);
|
||||||
|
if (agg)
|
||||||
|
return agg.outputKey;
|
||||||
|
const aliasMatch = raw.match(/^(.+?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
|
||||||
|
if (aliasMatch)
|
||||||
|
return aliasMatch[2];
|
||||||
|
return bareReference(raw);
|
||||||
|
};
|
||||||
|
if (stmt.orderBy) {
|
||||||
|
const resolved = [];
|
||||||
|
for (const item of stmt.orderBy) {
|
||||||
|
if (!/^\d+$/.test(item.column.trim()) || await hasRealColumn(item.column)) {
|
||||||
|
resolved.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
resolved.push({ ...item, column: outputKeyAt(Number(item.column.trim()), 'ORDER BY') });
|
||||||
|
}
|
||||||
|
stmt.orderBy = resolved;
|
||||||
|
}
|
||||||
|
if (stmt.groupBy) {
|
||||||
|
const resolved = [];
|
||||||
|
for (const key of stmt.groupBy) {
|
||||||
|
if (!/^\d+$/.test(key.trim()) || await hasRealColumn(key)) {
|
||||||
|
resolved.push(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const position = Number(key.trim());
|
||||||
|
const raw = outputs[position - 1];
|
||||||
|
if (raw !== undefined && parseAggregateExpression(raw)) {
|
||||||
|
throw new DatabaseError(`GROUP BY position ${position} refers to an aggregate expression ("${raw.trim()}")`, 'QUERY_ERROR', { position });
|
||||||
|
}
|
||||||
|
resolved.push(outputKeyAt(position, 'GROUP BY'));
|
||||||
|
}
|
||||||
|
stmt.groupBy = resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* 归一化"行键不带前缀"的查询中的所有引用 —— 剥离表别名前缀。
|
* 归一化"行键不带前缀"的查询中的所有引用 —— 剥离表别名前缀。
|
||||||
*
|
*
|
||||||
@@ -15464,11 +15697,18 @@ class QueryExecutor {
|
|||||||
if (stmt.where && Object.keys(stmt.where).length > 0) {
|
if (stmt.where && Object.keys(stmt.where).length > 0) {
|
||||||
stmt.where = this.normalizeWhereColumns(stmt.where, list);
|
stmt.where = this.normalizeWhereColumns(stmt.where, list);
|
||||||
}
|
}
|
||||||
|
// v0.8.0(B-5):ORDER BY / GROUP BY 的键也要脱引号 —— 与 WHERE 同一口径。
|
||||||
|
// 保留引号的唯一目的是让投影期区分"名为 1 的列(`"1"`)"与"常量 1(`1`)";
|
||||||
|
// 排序/分组阶段必须用**真实列名**,否则 `ORDER BY "1"` 取不到列值而静默
|
||||||
|
// 按原序返回、`GROUP BY "1"` 会在输出行里留下 `"1"` 与 `1` 两个键(均已实测)。
|
||||||
if (stmt.orderBy && stmt.orderBy.length > 0) {
|
if (stmt.orderBy && stmt.orderBy.length > 0) {
|
||||||
stmt.orderBy = stmt.orderBy.map((o) => ({ ...o, column: this.stripAlias(o.column, list) }));
|
stmt.orderBy = stmt.orderBy.map((o) => ({
|
||||||
|
...o,
|
||||||
|
column: unquoteIdentifier(this.stripAlias(o.column, list)),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
if (stmt.groupBy && stmt.groupBy.length > 0) {
|
if (stmt.groupBy && stmt.groupBy.length > 0) {
|
||||||
stmt.groupBy = stmt.groupBy.map((c) => this.stripAlias(c, list));
|
stmt.groupBy = stmt.groupBy.map((c) => unquoteIdentifier(this.stripAlias(c, list)));
|
||||||
}
|
}
|
||||||
stmt.columns = stmt.columns.map((c) => {
|
stmt.columns = stmt.columns.map((c) => {
|
||||||
if (c === '*' || isAggregateExpression(c) || /^\s*CASE\b/i.test(c) || /^'/.test(c))
|
if (c === '*' || isAggregateExpression(c) || /^\s*CASE\b/i.test(c) || /^'/.test(c))
|
||||||
@@ -15497,7 +15737,10 @@ class QueryExecutor {
|
|||||||
normalized[key] = this.normalizeExistsValue(value, aliases);
|
normalized[key] = this.normalizeExistsValue(value, aliases);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const newKey = this.stripAlias(key, aliases);
|
// v0.8.0(B-5):WHERE 键脱去分隔标识符引号(`"1" = 'y'` → `1`),
|
||||||
|
// 否则 matchWhere 用裸 `1` 取值、而行键也是裸 `1`,两者因为引号不同
|
||||||
|
// 永远匹配不上 → 静默空结果(实测)。引号到此已完成"区分列 vs 常量"的使命。
|
||||||
|
const newKey = unquoteIdentifier(this.stripAlias(key, aliases));
|
||||||
normalized[newKey] = this.normalizeFieldValue(value, aliases);
|
normalized[newKey] = this.normalizeFieldValue(value, aliases);
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -15526,11 +15769,11 @@ class QueryExecutor {
|
|||||||
ops[op] = this.normalizeFieldValue(operand, aliases);
|
ops[op] = this.normalizeFieldValue(operand, aliases);
|
||||||
}
|
}
|
||||||
else if (op === '$col') {
|
else if (op === '$col') {
|
||||||
ops[op] = this.stripAlias(String(operand), aliases);
|
ops[op] = unquoteIdentifier(this.stripAlias(String(operand), aliases));
|
||||||
}
|
}
|
||||||
else if (typeof operand === 'object' && operand !== null && !Array.isArray(operand) && '$col' in operand) {
|
else if (typeof operand === 'object' && operand !== null && !Array.isArray(operand) && '$col' in operand) {
|
||||||
// 操作符值中嵌套的列引用:{ $eq: { $col: 'u.id' } }
|
// 操作符值中嵌套的列引用:{ $eq: { $col: 'u.id' } }
|
||||||
ops[op] = { $col: this.stripAlias(String(operand.$col), aliases) };
|
ops[op] = { $col: unquoteIdentifier(this.stripAlias(String(operand.$col), aliases)) };
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
ops[op] = operand;
|
ops[op] = operand;
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+262
-19
@@ -1010,23 +1010,49 @@
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// 列投影
|
// 列投影
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
/**
|
||||||
|
* 列投影。
|
||||||
|
*
|
||||||
|
* v0.8.0(B-5):支持**分隔标识符**(`"1"`)与 JOIN 行的 `别名.列` 键。
|
||||||
|
* 此前只做"精确命中,否则找 `endsWith('.col')`",于是:
|
||||||
|
* - `SELECT "1" FROM q`(列名就叫 1)取不到值 → 输出 `{}`
|
||||||
|
* (校验用裸名、投影用带引号的名,两套规则 —— 典型漂移);
|
||||||
|
* - 找不到时**不产出键**,行形状随列是否存在而变。
|
||||||
|
* 现在统一:脱引号 → 精确命中 → 唯一后缀命中;仍找不到则不产出该键
|
||||||
|
*(是否"未知列"由 executor 的 assertProjectionColumnsExist 判定并报错,
|
||||||
|
* 投影层不做静默兜底)。
|
||||||
|
*/
|
||||||
function projectColumns(row, columns) {
|
function projectColumns(row, columns) {
|
||||||
const projected = {};
|
const projected = {};
|
||||||
for (const col of columns) {
|
for (const col of columns) {
|
||||||
if (col in row) {
|
const name = unquoteIdentifier$1(col);
|
||||||
projected[col] = row[col];
|
if (name in row) {
|
||||||
|
projected[name] = row[name];
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
else {
|
// JOIN 行键形如 `t.col`:唯一后缀匹配(多个命中视为歧义,取第一个与
|
||||||
|
// executor 的校验口径一致 —— 那里已对歧义报错,能走到这里说明唯一)
|
||||||
|
let found;
|
||||||
|
let hits = 0;
|
||||||
for (const key of Object.keys(row)) {
|
for (const key of Object.keys(row)) {
|
||||||
if (key.endsWith(`.${col}`) || key === col) {
|
if (key.endsWith(`.${name}`) || key === name) {
|
||||||
projected[col] = row[key];
|
found = row[key];
|
||||||
break;
|
hits += 1;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (hits >= 1)
|
||||||
|
projected[name] = found;
|
||||||
}
|
}
|
||||||
return projected;
|
return projected;
|
||||||
}
|
}
|
||||||
|
/** 脱去分隔标识符的引号(`"1"` → `1`,`"a""b"` → `a"b`) */
|
||||||
|
function unquoteIdentifier$1(text) {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||||
|
return trimmed.slice(1, -1).replace(/""/g, '"');
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* metona-sqlark 统一行校验 —— 存储写入的**唯一**验证与规范化入口(v0.8.0)
|
* metona-sqlark 统一行校验 —— 存储写入的**唯一**验证与规范化入口(v0.8.0)
|
||||||
@@ -11927,7 +11953,7 @@
|
|||||||
if (this.curTokenIs(TokenType.GROUP)) {
|
if (this.curTokenIs(TokenType.GROUP)) {
|
||||||
this.nextToken();
|
this.nextToken();
|
||||||
this.expect(TokenType.BY);
|
this.expect(TokenType.BY);
|
||||||
stmt.groupBy = this.parseIdentifierList();
|
stmt.groupBy = this.parseGroupByList();
|
||||||
}
|
}
|
||||||
// HAVING(可选)
|
// HAVING(可选)
|
||||||
if (this.curTokenIs(TokenType.HAVING)) {
|
if (this.curTokenIs(TokenType.HAVING)) {
|
||||||
@@ -12719,6 +12745,24 @@
|
|||||||
this.curTokenIs(TokenType.MAX)) {
|
this.curTokenIs(TokenType.MAX)) {
|
||||||
return this.parseAggregateCall();
|
return this.parseAggregateCall();
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-5):分隔标识符必须**保留引号**。
|
||||||
|
*
|
||||||
|
* 此前 `SELECT "1" FROM q`(列名就叫 `1`,建表时用引号声明)被解析成裸 `1`,
|
||||||
|
* 而投影阶段把裸数字当**常量**列 → 返回 `{"1": 1}`(字面量 1),
|
||||||
|
* 而 `SELECT *` 返回正确的 `{"1": "z"}` —— 同一列两种结论(实测)。
|
||||||
|
* 保留引号后,下游能区分"名为 1 的列"与"常量 1"。
|
||||||
|
*/
|
||||||
|
if (this.curTokenIs(TokenType.QUOTED_IDENTIFIER)) {
|
||||||
|
const name = this.curToken.value;
|
||||||
|
this.nextToken();
|
||||||
|
if (this.curTokenIs(TokenType.DOT)) {
|
||||||
|
this.nextToken();
|
||||||
|
const second = this.expectIdentifier('column name after "."');
|
||||||
|
return `"${name.replace(/"/g, '""')}".${second}`;
|
||||||
|
}
|
||||||
|
return `"${name.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
const first = this.expectIdentifier('column name');
|
const first = this.expectIdentifier('column name');
|
||||||
if (this.curTokenIs(TokenType.DOT)) {
|
if (this.curTokenIs(TokenType.DOT)) {
|
||||||
this.nextToken();
|
this.nextToken();
|
||||||
@@ -12818,6 +12862,40 @@
|
|||||||
}
|
}
|
||||||
return vals;
|
return vals;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-5):ORDER BY / GROUP BY 的键可以是**输出列序号**(SQL 标准)。
|
||||||
|
*
|
||||||
|
* `ORDER BY 1` 表示"按第 1 个输出列排序",`GROUP BY 2` 同理 ——
|
||||||
|
* 这在手写 SQL 与 UNION 里非常常用(各分支输出列名可能不同,只能按序号引用)。
|
||||||
|
*
|
||||||
|
* 此前 parser 的 `parseIdentifierWithDot` 只接受标识符,于是 `ORDER BY 1`
|
||||||
|
* 直接 `PARSE_ERROR: Expected identifier, got "1"`(实测)。
|
||||||
|
*
|
||||||
|
* 这里把序号**原样保留为数字字符串**(AST 形状不变),由 executor 在拿到
|
||||||
|
* SELECT 列表后再解析成对应表达式 —— 序号的含义依赖 SELECT 列表,
|
||||||
|
* parser 层无从判断。
|
||||||
|
*/
|
||||||
|
parseOrderOrGroupKey() {
|
||||||
|
if (this.curTokenIs(TokenType.NUMBER)) {
|
||||||
|
const value = this.curToken.value;
|
||||||
|
// 序号必须是正整数(`ORDER BY 0` / `ORDER BY 1.5` 非法)
|
||||||
|
if (!/^\d+$/.test(value) || Number(value) < 1) {
|
||||||
|
throw this.error(`Invalid output column ordinal "${value}" (must be a positive integer)`);
|
||||||
|
}
|
||||||
|
this.nextToken();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return this.parseIdentifierWithDot();
|
||||||
|
}
|
||||||
|
parseGroupByList() {
|
||||||
|
const list = [];
|
||||||
|
list.push(this.parseOrderOrGroupKey());
|
||||||
|
while (this.curTokenIs(TokenType.COMMA)) {
|
||||||
|
this.nextToken();
|
||||||
|
list.push(this.parseOrderOrGroupKey());
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
parseOrderByList() {
|
parseOrderByList() {
|
||||||
const list = [];
|
const list = [];
|
||||||
list.push(this.parseOrderBy());
|
list.push(this.parseOrderBy());
|
||||||
@@ -12828,7 +12906,7 @@
|
|||||||
return list;
|
return list;
|
||||||
}
|
}
|
||||||
parseOrderBy() {
|
parseOrderBy() {
|
||||||
const column = this.parseIdentifierWithDot();
|
const column = this.parseOrderOrGroupKey();
|
||||||
let direction = 'asc';
|
let direction = 'asc';
|
||||||
if (this.curTokenIs(TokenType.ASC)) {
|
if (this.curTokenIs(TokenType.ASC)) {
|
||||||
this.nextToken();
|
this.nextToken();
|
||||||
@@ -13009,7 +13087,13 @@
|
|||||||
* 静默取第一个正是"结果取决于表顺序"这类难查问题的来源。
|
* 静默取第一个正是"结果取决于表顺序"这类难查问题的来源。
|
||||||
*/
|
*/
|
||||||
function resolveColumnValue(row, reference, opts) {
|
function resolveColumnValue(row, reference, opts) {
|
||||||
const text = reference.trim();
|
let text = reference.trim();
|
||||||
|
// v0.8.0:分隔标识符(`"col"`)在比较/取值时要脱去引号 ——
|
||||||
|
// 保留引号是为了让投影阶段能区分"名为 1 的列"与"常量 1"(见 parser 说明),
|
||||||
|
// 但比较/取值必须用真实列名。`""` 是引号自身的转义。
|
||||||
|
if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) {
|
||||||
|
text = text.slice(1, -1).replace(/""/g, '"');
|
||||||
|
}
|
||||||
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` 作键,单表路径的键不带前缀
|
||||||
@@ -13574,7 +13658,29 @@
|
|||||||
const caseExpr = parseCaseExpression(item);
|
const caseExpr = parseCaseExpression(item);
|
||||||
if (caseExpr)
|
if (caseExpr)
|
||||||
return evaluateCase(caseExpr, row);
|
return evaluateCase(caseExpr, row);
|
||||||
return resolveColumnValue(row, item, { strict: true, context: 'GROUP BY' });
|
// 脱引号后再取值:解析层保留引号是为了区分"列 vs 常量",执行期必须用真实列名
|
||||||
|
//(否则 `GROUP BY "1"` 会在行里产出重复的 `1` 与 `"1"` 两个键 —— 实测)
|
||||||
|
return resolveColumnValue(row, unquoteIdentifier(item), { strict: true, context: 'GROUP BY' });
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-5):脱去分隔标识符的引号 —— `"1"` → `1`,`"a""b"` → `a"b`。
|
||||||
|
*
|
||||||
|
* 为什么 parser 保留引号、这里再脱:parser 必须保留才能区分
|
||||||
|
* "名为 1 的列"(`"1"`)与"常量 1"(`1`);而一旦进入执行期,列名就是
|
||||||
|
* schema 里的裸名字。**统一在这一个边界脱引号**,避免"投影用带引号的名字、
|
||||||
|
* 取值用裸名字"这类两套规则(那正是本项目反复出现的缺陷模式)。
|
||||||
|
*/
|
||||||
|
function unquoteIdentifier(text) {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||||
|
return trimmed.slice(1, -1).replace(/""/g, '"');
|
||||||
|
}
|
||||||
|
// `"alias"."col"` 形态:两侧都脱引号
|
||||||
|
if (trimmed.includes('.')) {
|
||||||
|
const parts = trimmed.split('.');
|
||||||
|
return parts.map((part) => unquoteIdentifier(part)).join('.');
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
}
|
}
|
||||||
function resolveAliasSource(source, row) {
|
function resolveAliasSource(source, row) {
|
||||||
const text = source.trim();
|
const text = source.trim();
|
||||||
@@ -13979,6 +14085,7 @@
|
|||||||
// 此前这里**不做**归一化:`SELECT d.id FROM (SELECT id, g FROM t) AS d`
|
// 此前这里**不做**归一化:`SELECT d.id FROM (SELECT id, g FROM t) AS d`
|
||||||
// 直接拿 `d.id` 去投影,行里只有 `id` → 静默返回 `[]`;
|
// 直接拿 `d.id` 去投影,行里只有 `id` → 静默返回 `[]`;
|
||||||
// 而同一查询写成 `SELECT id ...` 却正确 —— 同一行源两种写法结论相反。
|
// 而同一查询写成 `SELECT id ...` 却正确 —— 同一行源两种写法结论相反。
|
||||||
|
await this.resolveOutputOrdinals(stmt);
|
||||||
this.normalizeUnprefixedReferences(stmt, [stmt.alias ?? stmt.from]);
|
this.normalizeUnprefixedReferences(stmt, [stmt.alias ?? stmt.from]);
|
||||||
if (stmt.where && Object.keys(stmt.where).length > 0) {
|
if (stmt.where && Object.keys(stmt.where).length > 0) {
|
||||||
// 非 JOIN:WHERE 在 executor 端过滤(子查询结果不经引擎)
|
// 非 JOIN:WHERE 在 executor 端过滤(子查询结果不经引擎)
|
||||||
@@ -13996,6 +14103,10 @@
|
|||||||
rows = await this.executeJoinSelect(stmt);
|
rows = await this.executeJoinSelect(stmt);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
// v0.8.0(B-5):先把 ORDER BY / GROUP BY 的输出列序号解析为输出列名。
|
||||||
|
// 必须在归一化之前:序号 → 列名的映射依赖 SELECT 列表原文
|
||||||
|
//(`SELECT u.n FROM t u ORDER BY 1` → `u.n`,归一化后再解析会丢前缀)。
|
||||||
|
await this.resolveOutputOrdinals(stmt);
|
||||||
// 非 JOIN 路径:行键不带别名前缀 → 统一归一化引用
|
// 非 JOIN 路径:行键不带别名前缀 → 统一归一化引用
|
||||||
const mainAliases = [stmt.alias ?? stmt.from].filter(Boolean);
|
const mainAliases = [stmt.alias ?? stmt.from].filter(Boolean);
|
||||||
this.normalizeUnprefixedReferences(stmt, mainAliases);
|
this.normalizeUnprefixedReferences(stmt, mainAliases);
|
||||||
@@ -14420,7 +14531,7 @@
|
|||||||
// → 用**别名** `band`,因为 projectGroupedRow 按别名索引;
|
// → 用**别名** `band`,因为 projectGroupedRow 按别名索引;
|
||||||
// - 裸列 → 用列名。
|
// - 裸列 → 用列名。
|
||||||
const caseExpr = parseCaseExpression(col);
|
const caseExpr = parseCaseExpression(col);
|
||||||
const key = caseExpr?.alias ?? col;
|
const key = caseExpr?.alias ?? unquoteIdentifier(col);
|
||||||
aggregated[key] = resolveGroupKeyValue(first, col);
|
aggregated[key] = resolveGroupKeyValue(first, col);
|
||||||
}
|
}
|
||||||
// 先算聚合(含仅 HAVING 引用的),统一以 exprKey 与输出键写入
|
// 先算聚合(含仅 HAVING 引用的),统一以 exprKey 与输出键写入
|
||||||
@@ -14520,7 +14631,9 @@
|
|||||||
output[aliasMatch[2]] = aggregated[aliasMatch[2]];
|
output[aliasMatch[2]] = aggregated[aliasMatch[2]];
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const base = bareReference(colExpr);
|
// v0.8.0(B-5):脱引号 —— `SELECT "1" ... GROUP BY "1"` 的输出键必须与
|
||||||
|
// 行里的真实列名一致,否则会同时出现 `1` 与 `"1"` 两个键(实测)。
|
||||||
|
const base = unquoteIdentifier(bareReference(colExpr));
|
||||||
output[base] = base in aggregated ? aggregated[base] : aggregated[colExpr];
|
output[base] = base in aggregated ? aggregated[base] : aggregated[colExpr];
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
@@ -15180,7 +15293,10 @@
|
|||||||
const missing = [];
|
const missing = [];
|
||||||
const ambiguous = [];
|
const ambiguous = [];
|
||||||
const check = (ref) => {
|
const check = (ref) => {
|
||||||
const text = ref.trim();
|
// v0.8.0(B-5):校验用**真实列名** —— 分隔标识符(`"1"`)在 WHERE 里
|
||||||
|
// 同样是列引用,必须脱引号后再比对(此前会报 `Unknown column "\"1\""`
|
||||||
|
// —— 错误消息里带着引号,用户看不出问题在哪)。
|
||||||
|
const text = unquoteIdentifier(ref);
|
||||||
if (!text)
|
if (!text)
|
||||||
return;
|
return;
|
||||||
if (available.has(text)) {
|
if (available.has(text)) {
|
||||||
@@ -15326,6 +15442,9 @@
|
|||||||
const aliasMatch = col.match(/^(.+?)\s+AS\s+\w+$/i);
|
const aliasMatch = col.match(/^(.+?)\s+AS\s+\w+$/i);
|
||||||
if (aliasMatch)
|
if (aliasMatch)
|
||||||
reference = aliasMatch[1].trim();
|
reference = aliasMatch[1].trim();
|
||||||
|
// v0.8.0(B-5):校验用的是**真实列名**,因此这里脱去分隔标识符的引号
|
||||||
|
//(`"1"` → `1`);保留引号只在投影期用于区分"列 vs 常量"。
|
||||||
|
reference = unquoteIdentifier(reference);
|
||||||
// 常量列(字符串 / 数字 / 布尔 / NULL)
|
// 常量列(字符串 / 数字 / 布尔 / NULL)
|
||||||
if (/^'.*'$/s.test(reference))
|
if (/^'.*'$/s.test(reference))
|
||||||
continue;
|
continue;
|
||||||
@@ -15394,6 +15513,15 @@
|
|||||||
constCols.push({ key: col, value: resolveAliasSource(col, row) });
|
constCols.push({ key: col, value: resolveAliasSource(col, row) });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// v0.8.0(B-5):分隔标识符(`"1"`)是**列引用**,不是常量 —— 在这里脱引号
|
||||||
|
// 落到 plain 分支走正常列投影(输出键即裸列名,与 `SELECT *` 一致)。
|
||||||
|
// 脱引号必须在本函数内完成:更早脱会让 `"1"` 被上面的裸数字常量子句吃掉
|
||||||
|
// (实测 `SELECT "1" FROM q` 返回 `{"1":1}` —— 常量 1 而非列值)。
|
||||||
|
const unquoted = unquoteIdentifier(col);
|
||||||
|
if (unquoted !== col) {
|
||||||
|
plain.push(unquoted);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
plain.push(col);
|
plain.push(col);
|
||||||
}
|
}
|
||||||
// v0.7.3: hasStar 时以原行全部列为基(projectColumns 仅投影 plain 列,不含 * 的其余列)
|
// v0.7.3: hasStar 时以原行全部列为基(projectColumns 仅投影 plain 列,不含 * 的其余列)
|
||||||
@@ -15452,6 +15580,111 @@
|
|||||||
// ===================================================================
|
// ===================================================================
|
||||||
// 关联子查询 / 别名规范化(v0.3.0)
|
// 关联子查询 / 别名规范化(v0.3.0)
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-5):把 ORDER BY / GROUP BY 里的**输出列序号**解析为输出列名。
|
||||||
|
*
|
||||||
|
* SQL 标准允许 `ORDER BY 1` / `GROUP BY 2` 按输出列位置引用(UNION 各分支
|
||||||
|
* 列名可能不同,只能按序号引用)。规则:
|
||||||
|
* 1. 若存在**同名的真实列**(如列名就是 `"1"`,用双引号建表),按列名优先 ——
|
||||||
|
* 显式标识符胜过位置简写;
|
||||||
|
* 2. 纯数字 → 第 N 个输出列的**表达式原文**(`SELECT n AS num ... ORDER BY 1`
|
||||||
|
* 解析为 `num`,因为投影后行里只有 `num`);
|
||||||
|
* 3. 序号越界 → `QUERY_ERROR`(不说"未知列",因为问题出在位置而不是名字);
|
||||||
|
* 4. `GROUP BY <序号>` 指向聚合表达式 → `QUERY_ERROR`
|
||||||
|
* (按聚合值分组语义上不成立,SQL 标准同样禁止)。
|
||||||
|
*/
|
||||||
|
async resolveOutputOrdinals(stmt) {
|
||||||
|
const outputs = stmt.columns;
|
||||||
|
// 真实列名集合(用于优先级判定)—— 只在确有"纯数字键"时才去取 schema
|
||||||
|
let realColumns = null;
|
||||||
|
const loadRealColumns = async () => {
|
||||||
|
if (realColumns)
|
||||||
|
return realColumns;
|
||||||
|
const names = new Set();
|
||||||
|
const addTable = async (table) => {
|
||||||
|
const schema = await this.engine.getTableSchema(table);
|
||||||
|
if (!schema)
|
||||||
|
return;
|
||||||
|
for (const col of Object.keys(schema.columns))
|
||||||
|
names.add(col);
|
||||||
|
};
|
||||||
|
if (stmt.from)
|
||||||
|
await addTable(stmt.from);
|
||||||
|
for (const join of stmt.joins ?? [])
|
||||||
|
await addTable(join.table);
|
||||||
|
realColumns = names;
|
||||||
|
return names;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 判断一个纯数字键是否应当**按列名**解释(而不是按输出列位置)。
|
||||||
|
*
|
||||||
|
* 规则(与 SQLite / SQL 标准的名称解析一致):
|
||||||
|
* 1. SELECT 列表里有同名的**输出列**(裸名或别名)→ 列名引用;
|
||||||
|
* 2. 行源里存在同名**真实列**,且该列在 SELECT 列表里以**未加引号的同名**
|
||||||
|
* 形式出现 → 列名引用;
|
||||||
|
* 3. 其余情况(含"行源有名为 `"1"` 的列,但查询里写的是裸 `1`")→ 位置序号。
|
||||||
|
*
|
||||||
|
* 第 3 条是关键:`"1"`(引号标识符)与 `1`(数字字面量)在 SQL 里是**不同的
|
||||||
|
* 东西**。此前把未加引号的 `1` 拿去 schema 里查"是否存在列 1",于是
|
||||||
|
* `SELECT other FROM q ORDER BY 1 DESC` 因 q 恰好有名为 `"1"` 的列而被当成
|
||||||
|
* 普通列排序 —— **DESC 丢失**(实测)。想按那一列排序必须写 `ORDER BY "1"`。
|
||||||
|
*/
|
||||||
|
const hasRealColumn = async (key) => {
|
||||||
|
const trimmed = key.trim();
|
||||||
|
if (outputs.some((o) => bareReference(o) === trimmed || new RegExp(`\\bAS\\s+${trimmed}$`, 'i').test(o))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const columns = await loadRealColumns();
|
||||||
|
if (!columns.has(trimmed))
|
||||||
|
return false;
|
||||||
|
// 只有"查询里以裸名形式引用了该列"才算列名引用
|
||||||
|
return outputs.some((o) => o === trimmed);
|
||||||
|
};
|
||||||
|
/** 序号 → 输出列名(投影后行里的键) */
|
||||||
|
const outputKeyAt = (position, context) => {
|
||||||
|
if (position < 1 || position > outputs.length) {
|
||||||
|
throw new DatabaseError(`${context} position ${position} is out of range: query has ${outputs.length} output column(s)`, 'QUERY_ERROR', { position, outputs: outputs.length });
|
||||||
|
}
|
||||||
|
const raw = outputs[position - 1];
|
||||||
|
const caseExpr = parseCaseExpression(raw);
|
||||||
|
if (caseExpr)
|
||||||
|
return caseExpr.alias ?? raw;
|
||||||
|
const agg = parseAggregateExpression(raw);
|
||||||
|
if (agg)
|
||||||
|
return agg.outputKey;
|
||||||
|
const aliasMatch = raw.match(/^(.+?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
|
||||||
|
if (aliasMatch)
|
||||||
|
return aliasMatch[2];
|
||||||
|
return bareReference(raw);
|
||||||
|
};
|
||||||
|
if (stmt.orderBy) {
|
||||||
|
const resolved = [];
|
||||||
|
for (const item of stmt.orderBy) {
|
||||||
|
if (!/^\d+$/.test(item.column.trim()) || await hasRealColumn(item.column)) {
|
||||||
|
resolved.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
resolved.push({ ...item, column: outputKeyAt(Number(item.column.trim()), 'ORDER BY') });
|
||||||
|
}
|
||||||
|
stmt.orderBy = resolved;
|
||||||
|
}
|
||||||
|
if (stmt.groupBy) {
|
||||||
|
const resolved = [];
|
||||||
|
for (const key of stmt.groupBy) {
|
||||||
|
if (!/^\d+$/.test(key.trim()) || await hasRealColumn(key)) {
|
||||||
|
resolved.push(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const position = Number(key.trim());
|
||||||
|
const raw = outputs[position - 1];
|
||||||
|
if (raw !== undefined && parseAggregateExpression(raw)) {
|
||||||
|
throw new DatabaseError(`GROUP BY position ${position} refers to an aggregate expression ("${raw.trim()}")`, 'QUERY_ERROR', { position });
|
||||||
|
}
|
||||||
|
resolved.push(outputKeyAt(position, 'GROUP BY'));
|
||||||
|
}
|
||||||
|
stmt.groupBy = resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* 归一化"行键不带前缀"的查询中的所有引用 —— 剥离表别名前缀。
|
* 归一化"行键不带前缀"的查询中的所有引用 —— 剥离表别名前缀。
|
||||||
*
|
*
|
||||||
@@ -15470,11 +15703,18 @@
|
|||||||
if (stmt.where && Object.keys(stmt.where).length > 0) {
|
if (stmt.where && Object.keys(stmt.where).length > 0) {
|
||||||
stmt.where = this.normalizeWhereColumns(stmt.where, list);
|
stmt.where = this.normalizeWhereColumns(stmt.where, list);
|
||||||
}
|
}
|
||||||
|
// v0.8.0(B-5):ORDER BY / GROUP BY 的键也要脱引号 —— 与 WHERE 同一口径。
|
||||||
|
// 保留引号的唯一目的是让投影期区分"名为 1 的列(`"1"`)"与"常量 1(`1`)";
|
||||||
|
// 排序/分组阶段必须用**真实列名**,否则 `ORDER BY "1"` 取不到列值而静默
|
||||||
|
// 按原序返回、`GROUP BY "1"` 会在输出行里留下 `"1"` 与 `1` 两个键(均已实测)。
|
||||||
if (stmt.orderBy && stmt.orderBy.length > 0) {
|
if (stmt.orderBy && stmt.orderBy.length > 0) {
|
||||||
stmt.orderBy = stmt.orderBy.map((o) => ({ ...o, column: this.stripAlias(o.column, list) }));
|
stmt.orderBy = stmt.orderBy.map((o) => ({
|
||||||
|
...o,
|
||||||
|
column: unquoteIdentifier(this.stripAlias(o.column, list)),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
if (stmt.groupBy && stmt.groupBy.length > 0) {
|
if (stmt.groupBy && stmt.groupBy.length > 0) {
|
||||||
stmt.groupBy = stmt.groupBy.map((c) => this.stripAlias(c, list));
|
stmt.groupBy = stmt.groupBy.map((c) => unquoteIdentifier(this.stripAlias(c, list)));
|
||||||
}
|
}
|
||||||
stmt.columns = stmt.columns.map((c) => {
|
stmt.columns = stmt.columns.map((c) => {
|
||||||
if (c === '*' || isAggregateExpression(c) || /^\s*CASE\b/i.test(c) || /^'/.test(c))
|
if (c === '*' || isAggregateExpression(c) || /^\s*CASE\b/i.test(c) || /^'/.test(c))
|
||||||
@@ -15503,7 +15743,10 @@
|
|||||||
normalized[key] = this.normalizeExistsValue(value, aliases);
|
normalized[key] = this.normalizeExistsValue(value, aliases);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const newKey = this.stripAlias(key, aliases);
|
// v0.8.0(B-5):WHERE 键脱去分隔标识符引号(`"1" = 'y'` → `1`),
|
||||||
|
// 否则 matchWhere 用裸 `1` 取值、而行键也是裸 `1`,两者因为引号不同
|
||||||
|
// 永远匹配不上 → 静默空结果(实测)。引号到此已完成"区分列 vs 常量"的使命。
|
||||||
|
const newKey = unquoteIdentifier(this.stripAlias(key, aliases));
|
||||||
normalized[newKey] = this.normalizeFieldValue(value, aliases);
|
normalized[newKey] = this.normalizeFieldValue(value, aliases);
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -15532,11 +15775,11 @@
|
|||||||
ops[op] = this.normalizeFieldValue(operand, aliases);
|
ops[op] = this.normalizeFieldValue(operand, aliases);
|
||||||
}
|
}
|
||||||
else if (op === '$col') {
|
else if (op === '$col') {
|
||||||
ops[op] = this.stripAlias(String(operand), aliases);
|
ops[op] = unquoteIdentifier(this.stripAlias(String(operand), aliases));
|
||||||
}
|
}
|
||||||
else if (typeof operand === 'object' && operand !== null && !Array.isArray(operand) && '$col' in operand) {
|
else if (typeof operand === 'object' && operand !== null && !Array.isArray(operand) && '$col' in operand) {
|
||||||
// 操作符值中嵌套的列引用:{ $eq: { $col: 'u.id' } }
|
// 操作符值中嵌套的列引用:{ $eq: { $col: 'u.id' } }
|
||||||
ops[op] = { $col: this.stripAlias(String(operand.$col), aliases) };
|
ops[op] = { $col: unquoteIdentifier(this.stripAlias(String(operand.$col), aliases)) };
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
ops[op] = operand;
|
ops[op] = operand;
|
||||||
|
|||||||
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
@@ -56,7 +56,13 @@ export function resolveColumnValue(
|
|||||||
reference: string,
|
reference: string,
|
||||||
opts: ResolveOptions,
|
opts: ResolveOptions,
|
||||||
): unknown {
|
): unknown {
|
||||||
const text = reference.trim();
|
let text = reference.trim();
|
||||||
|
// v0.8.0:分隔标识符(`"col"`)在比较/取值时要脱去引号 ——
|
||||||
|
// 保留引号是为了让投影阶段能区分"名为 1 的列"与"常量 1"(见 parser 说明),
|
||||||
|
// 但比较/取值必须用真实列名。`""` 是引号自身的转义。
|
||||||
|
if (text.length >= 2 && text.startsWith('"') && text.endsWith('"')) {
|
||||||
|
text = text.slice(1, -1).replace(/""/g, '"');
|
||||||
|
}
|
||||||
if (text in row) return row[text];
|
if (text in row) return row[text];
|
||||||
|
|
||||||
// 别名前缀(`t.n` → `n`):JOIN 行用 `alias.col` 作键,单表路径的键不带前缀
|
// 别名前缀(`t.n` → `n`):JOIN 行用 `alias.col` 作键,单表路径的键不带前缀
|
||||||
|
|||||||
+173
-9
@@ -193,7 +193,30 @@ function bareReference(reference: string): string {
|
|||||||
function resolveGroupKeyValue(row: Record<string, unknown>, item: string): unknown {
|
function resolveGroupKeyValue(row: Record<string, unknown>, item: string): unknown {
|
||||||
const caseExpr = parseCaseExpression(item);
|
const caseExpr = parseCaseExpression(item);
|
||||||
if (caseExpr) return evaluateCase(caseExpr, row);
|
if (caseExpr) return evaluateCase(caseExpr, row);
|
||||||
return resolveColumnValue(row, item, { strict: true, context: 'GROUP BY' });
|
// 脱引号后再取值:解析层保留引号是为了区分"列 vs 常量",执行期必须用真实列名
|
||||||
|
//(否则 `GROUP BY "1"` 会在行里产出重复的 `1` 与 `"1"` 两个键 —— 实测)
|
||||||
|
return resolveColumnValue(row, unquoteIdentifier(item), { strict: true, context: 'GROUP BY' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-5):脱去分隔标识符的引号 —— `"1"` → `1`,`"a""b"` → `a"b`。
|
||||||
|
*
|
||||||
|
* 为什么 parser 保留引号、这里再脱:parser 必须保留才能区分
|
||||||
|
* "名为 1 的列"(`"1"`)与"常量 1"(`1`);而一旦进入执行期,列名就是
|
||||||
|
* schema 里的裸名字。**统一在这一个边界脱引号**,避免"投影用带引号的名字、
|
||||||
|
* 取值用裸名字"这类两套规则(那正是本项目反复出现的缺陷模式)。
|
||||||
|
*/
|
||||||
|
function unquoteIdentifier(text: string): string {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||||
|
return trimmed.slice(1, -1).replace(/""/g, '"');
|
||||||
|
}
|
||||||
|
// `"alias"."col"` 形态:两侧都脱引号
|
||||||
|
if (trimmed.includes('.')) {
|
||||||
|
const parts = trimmed.split('.');
|
||||||
|
return parts.map((part) => unquoteIdentifier(part)).join('.');
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveAliasSource(source: string, row: Record<string, unknown>): unknown {
|
function resolveAliasSource(source: string, row: Record<string, unknown>): unknown {
|
||||||
@@ -620,6 +643,7 @@ export class QueryExecutor {
|
|||||||
// 此前这里**不做**归一化:`SELECT d.id FROM (SELECT id, g FROM t) AS d`
|
// 此前这里**不做**归一化:`SELECT d.id FROM (SELECT id, g FROM t) AS d`
|
||||||
// 直接拿 `d.id` 去投影,行里只有 `id` → 静默返回 `[]`;
|
// 直接拿 `d.id` 去投影,行里只有 `id` → 静默返回 `[]`;
|
||||||
// 而同一查询写成 `SELECT id ...` 却正确 —— 同一行源两种写法结论相反。
|
// 而同一查询写成 `SELECT id ...` 却正确 —— 同一行源两种写法结论相反。
|
||||||
|
await this.resolveOutputOrdinals(stmt);
|
||||||
this.normalizeUnprefixedReferences(stmt, [stmt.alias ?? stmt.from]);
|
this.normalizeUnprefixedReferences(stmt, [stmt.alias ?? stmt.from]);
|
||||||
if (stmt.where && Object.keys(stmt.where).length > 0) {
|
if (stmt.where && Object.keys(stmt.where).length > 0) {
|
||||||
// 非 JOIN:WHERE 在 executor 端过滤(子查询结果不经引擎)
|
// 非 JOIN:WHERE 在 executor 端过滤(子查询结果不经引擎)
|
||||||
@@ -634,6 +658,11 @@ export class QueryExecutor {
|
|||||||
// JOIN 路径:行带表别名前缀(如 'd.id'),WHERE 保持原名不剥离
|
// JOIN 路径:行带表别名前缀(如 'd.id'),WHERE 保持原名不剥离
|
||||||
rows = await this.executeJoinSelect(stmt);
|
rows = await this.executeJoinSelect(stmt);
|
||||||
} else {
|
} else {
|
||||||
|
// v0.8.0(B-5):先把 ORDER BY / GROUP BY 的输出列序号解析为输出列名。
|
||||||
|
// 必须在归一化之前:序号 → 列名的映射依赖 SELECT 列表原文
|
||||||
|
//(`SELECT u.n FROM t u ORDER BY 1` → `u.n`,归一化后再解析会丢前缀)。
|
||||||
|
await this.resolveOutputOrdinals(stmt);
|
||||||
|
|
||||||
// 非 JOIN 路径:行键不带别名前缀 → 统一归一化引用
|
// 非 JOIN 路径:行键不带别名前缀 → 统一归一化引用
|
||||||
const mainAliases = [stmt.alias ?? stmt.from].filter(Boolean);
|
const mainAliases = [stmt.alias ?? stmt.from].filter(Boolean);
|
||||||
this.normalizeUnprefixedReferences(stmt, mainAliases);
|
this.normalizeUnprefixedReferences(stmt, mainAliases);
|
||||||
@@ -1059,7 +1088,7 @@ export class QueryExecutor {
|
|||||||
// → 用**别名** `band`,因为 projectGroupedRow 按别名索引;
|
// → 用**别名** `band`,因为 projectGroupedRow 按别名索引;
|
||||||
// - 裸列 → 用列名。
|
// - 裸列 → 用列名。
|
||||||
const caseExpr = parseCaseExpression(col);
|
const caseExpr = parseCaseExpression(col);
|
||||||
const key = caseExpr?.alias ?? col;
|
const key = caseExpr?.alias ?? unquoteIdentifier(col);
|
||||||
aggregated[key] = resolveGroupKeyValue(first, col);
|
aggregated[key] = resolveGroupKeyValue(first, col);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1159,7 +1188,9 @@ export class QueryExecutor {
|
|||||||
output[aliasMatch[2]] = aggregated[aliasMatch[2]];
|
output[aliasMatch[2]] = aggregated[aliasMatch[2]];
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const base = bareReference(colExpr);
|
// v0.8.0(B-5):脱引号 —— `SELECT "1" ... GROUP BY "1"` 的输出键必须与
|
||||||
|
// 行里的真实列名一致,否则会同时出现 `1` 与 `"1"` 两个键(实测)。
|
||||||
|
const base = unquoteIdentifier(bareReference(colExpr));
|
||||||
output[base] = base in aggregated ? aggregated[base] : aggregated[colExpr];
|
output[base] = base in aggregated ? aggregated[base] : aggregated[colExpr];
|
||||||
}
|
}
|
||||||
return output;
|
return output;
|
||||||
@@ -1861,7 +1892,10 @@ export class QueryExecutor {
|
|||||||
const missing: string[] = [];
|
const missing: string[] = [];
|
||||||
const ambiguous: string[] = [];
|
const ambiguous: string[] = [];
|
||||||
const check = (ref: string): void => {
|
const check = (ref: string): void => {
|
||||||
const text = ref.trim();
|
// v0.8.0(B-5):校验用**真实列名** —— 分隔标识符(`"1"`)在 WHERE 里
|
||||||
|
// 同样是列引用,必须脱引号后再比对(此前会报 `Unknown column "\"1\""`
|
||||||
|
// —— 错误消息里带着引号,用户看不出问题在哪)。
|
||||||
|
const text = unquoteIdentifier(ref);
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
if (available.has(text)) {
|
if (available.has(text)) {
|
||||||
// 裸列名在 JOIN 中若被多张表共有 → 歧义(SQL 标准要求限定)
|
// 裸列名在 JOIN 中若被多张表共有 → 歧义(SQL 标准要求限定)
|
||||||
@@ -2009,6 +2043,9 @@ export class QueryExecutor {
|
|||||||
let reference = col;
|
let reference = col;
|
||||||
const aliasMatch = col.match(/^(.+?)\s+AS\s+\w+$/i);
|
const aliasMatch = col.match(/^(.+?)\s+AS\s+\w+$/i);
|
||||||
if (aliasMatch) reference = aliasMatch[1].trim();
|
if (aliasMatch) reference = aliasMatch[1].trim();
|
||||||
|
// v0.8.0(B-5):校验用的是**真实列名**,因此这里脱去分隔标识符的引号
|
||||||
|
//(`"1"` → `1`);保留引号只在投影期用于区分"列 vs 常量"。
|
||||||
|
reference = unquoteIdentifier(reference);
|
||||||
|
|
||||||
// 常量列(字符串 / 数字 / 布尔 / NULL)
|
// 常量列(字符串 / 数字 / 布尔 / NULL)
|
||||||
if (/^'.*'$/s.test(reference)) continue;
|
if (/^'.*'$/s.test(reference)) continue;
|
||||||
@@ -2084,6 +2121,15 @@ export class QueryExecutor {
|
|||||||
constCols.push({ key: col, value: resolveAliasSource(col, row) });
|
constCols.push({ key: col, value: resolveAliasSource(col, row) });
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
// v0.8.0(B-5):分隔标识符(`"1"`)是**列引用**,不是常量 —— 在这里脱引号
|
||||||
|
// 落到 plain 分支走正常列投影(输出键即裸列名,与 `SELECT *` 一致)。
|
||||||
|
// 脱引号必须在本函数内完成:更早脱会让 `"1"` 被上面的裸数字常量子句吃掉
|
||||||
|
// (实测 `SELECT "1" FROM q` 返回 `{"1":1}` —— 常量 1 而非列值)。
|
||||||
|
const unquoted = unquoteIdentifier(col);
|
||||||
|
if (unquoted !== col) {
|
||||||
|
plain.push(unquoted);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
plain.push(col);
|
plain.push(col);
|
||||||
}
|
}
|
||||||
// v0.7.3: hasStar 时以原行全部列为基(projectColumns 仅投影 plain 列,不含 * 的其余列)
|
// v0.7.3: hasStar 时以原行全部列为基(projectColumns 仅投影 plain 列,不含 * 的其余列)
|
||||||
@@ -2145,6 +2191,114 @@ export class QueryExecutor {
|
|||||||
// 关联子查询 / 别名规范化(v0.3.0)
|
// 关联子查询 / 别名规范化(v0.3.0)
|
||||||
// ===================================================================
|
// ===================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-5):把 ORDER BY / GROUP BY 里的**输出列序号**解析为输出列名。
|
||||||
|
*
|
||||||
|
* SQL 标准允许 `ORDER BY 1` / `GROUP BY 2` 按输出列位置引用(UNION 各分支
|
||||||
|
* 列名可能不同,只能按序号引用)。规则:
|
||||||
|
* 1. 若存在**同名的真实列**(如列名就是 `"1"`,用双引号建表),按列名优先 ——
|
||||||
|
* 显式标识符胜过位置简写;
|
||||||
|
* 2. 纯数字 → 第 N 个输出列的**表达式原文**(`SELECT n AS num ... ORDER BY 1`
|
||||||
|
* 解析为 `num`,因为投影后行里只有 `num`);
|
||||||
|
* 3. 序号越界 → `QUERY_ERROR`(不说"未知列",因为问题出在位置而不是名字);
|
||||||
|
* 4. `GROUP BY <序号>` 指向聚合表达式 → `QUERY_ERROR`
|
||||||
|
* (按聚合值分组语义上不成立,SQL 标准同样禁止)。
|
||||||
|
*/
|
||||||
|
private async resolveOutputOrdinals(stmt: SelectStatement): Promise<void> {
|
||||||
|
const outputs = stmt.columns;
|
||||||
|
// 真实列名集合(用于优先级判定)—— 只在确有"纯数字键"时才去取 schema
|
||||||
|
let realColumns: Set<string> | null = null;
|
||||||
|
const loadRealColumns = async (): Promise<Set<string>> => {
|
||||||
|
if (realColumns) return realColumns;
|
||||||
|
const names = new Set<string>();
|
||||||
|
const addTable = async (table: string): Promise<void> => {
|
||||||
|
const schema = await this.engine.getTableSchema(table);
|
||||||
|
if (!schema) return;
|
||||||
|
for (const col of Object.keys(schema.columns)) names.add(col);
|
||||||
|
};
|
||||||
|
if (stmt.from) await addTable(stmt.from);
|
||||||
|
for (const join of stmt.joins ?? []) await addTable(join.table);
|
||||||
|
realColumns = names;
|
||||||
|
return names;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* 判断一个纯数字键是否应当**按列名**解释(而不是按输出列位置)。
|
||||||
|
*
|
||||||
|
* 规则(与 SQLite / SQL 标准的名称解析一致):
|
||||||
|
* 1. SELECT 列表里有同名的**输出列**(裸名或别名)→ 列名引用;
|
||||||
|
* 2. 行源里存在同名**真实列**,且该列在 SELECT 列表里以**未加引号的同名**
|
||||||
|
* 形式出现 → 列名引用;
|
||||||
|
* 3. 其余情况(含"行源有名为 `"1"` 的列,但查询里写的是裸 `1`")→ 位置序号。
|
||||||
|
*
|
||||||
|
* 第 3 条是关键:`"1"`(引号标识符)与 `1`(数字字面量)在 SQL 里是**不同的
|
||||||
|
* 东西**。此前把未加引号的 `1` 拿去 schema 里查"是否存在列 1",于是
|
||||||
|
* `SELECT other FROM q ORDER BY 1 DESC` 因 q 恰好有名为 `"1"` 的列而被当成
|
||||||
|
* 普通列排序 —— **DESC 丢失**(实测)。想按那一列排序必须写 `ORDER BY "1"`。
|
||||||
|
*/
|
||||||
|
const hasRealColumn = async (key: string): Promise<boolean> => {
|
||||||
|
const trimmed = key.trim();
|
||||||
|
if (outputs.some((o) => bareReference(o) === trimmed || new RegExp(`\\bAS\\s+${trimmed}$`, 'i').test(o))) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const columns = await loadRealColumns();
|
||||||
|
if (!columns.has(trimmed)) return false;
|
||||||
|
// 只有"查询里以裸名形式引用了该列"才算列名引用
|
||||||
|
return outputs.some((o) => o === trimmed);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 序号 → 输出列名(投影后行里的键) */
|
||||||
|
const outputKeyAt = (position: number, context: string): string => {
|
||||||
|
if (position < 1 || position > outputs.length) {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`${context} position ${position} is out of range: query has ${outputs.length} output column(s)`,
|
||||||
|
'QUERY_ERROR',
|
||||||
|
{ position, outputs: outputs.length },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const raw = outputs[position - 1];
|
||||||
|
const caseExpr = parseCaseExpression(raw);
|
||||||
|
if (caseExpr) return caseExpr.alias ?? raw;
|
||||||
|
const agg = parseAggregateExpression(raw);
|
||||||
|
if (agg) return agg.outputKey;
|
||||||
|
const aliasMatch = raw.match(/^(.+?)\s+AS\s+([A-Za-z_][A-Za-z0-9_]*)$/i);
|
||||||
|
if (aliasMatch) return aliasMatch[2];
|
||||||
|
return bareReference(raw);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (stmt.orderBy) {
|
||||||
|
const resolved: OrderBy[] = [];
|
||||||
|
for (const item of stmt.orderBy) {
|
||||||
|
if (!/^\d+$/.test(item.column.trim()) || await hasRealColumn(item.column)) {
|
||||||
|
resolved.push(item);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
resolved.push({ ...item, column: outputKeyAt(Number(item.column.trim()), 'ORDER BY') });
|
||||||
|
}
|
||||||
|
stmt.orderBy = resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stmt.groupBy) {
|
||||||
|
const resolved: string[] = [];
|
||||||
|
for (const key of stmt.groupBy) {
|
||||||
|
if (!/^\d+$/.test(key.trim()) || await hasRealColumn(key)) {
|
||||||
|
resolved.push(key);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const position = Number(key.trim());
|
||||||
|
const raw = outputs[position - 1];
|
||||||
|
if (raw !== undefined && parseAggregateExpression(raw)) {
|
||||||
|
throw new DatabaseError(
|
||||||
|
`GROUP BY position ${position} refers to an aggregate expression ("${raw.trim()}")`,
|
||||||
|
'QUERY_ERROR',
|
||||||
|
{ position },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
resolved.push(outputKeyAt(position, 'GROUP BY'));
|
||||||
|
}
|
||||||
|
stmt.groupBy = resolved;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 归一化"行键不带前缀"的查询中的所有引用 —— 剥离表别名前缀。
|
* 归一化"行键不带前缀"的查询中的所有引用 —— 剥离表别名前缀。
|
||||||
*
|
*
|
||||||
@@ -2166,11 +2320,18 @@ export class QueryExecutor {
|
|||||||
if (stmt.where && Object.keys(stmt.where).length > 0) {
|
if (stmt.where && Object.keys(stmt.where).length > 0) {
|
||||||
stmt.where = this.normalizeWhereColumns(stmt.where, list);
|
stmt.where = this.normalizeWhereColumns(stmt.where, list);
|
||||||
}
|
}
|
||||||
|
// v0.8.0(B-5):ORDER BY / GROUP BY 的键也要脱引号 —— 与 WHERE 同一口径。
|
||||||
|
// 保留引号的唯一目的是让投影期区分"名为 1 的列(`"1"`)"与"常量 1(`1`)";
|
||||||
|
// 排序/分组阶段必须用**真实列名**,否则 `ORDER BY "1"` 取不到列值而静默
|
||||||
|
// 按原序返回、`GROUP BY "1"` 会在输出行里留下 `"1"` 与 `1` 两个键(均已实测)。
|
||||||
if (stmt.orderBy && stmt.orderBy.length > 0) {
|
if (stmt.orderBy && stmt.orderBy.length > 0) {
|
||||||
stmt.orderBy = stmt.orderBy.map((o) => ({ ...o, column: this.stripAlias(o.column, list) }));
|
stmt.orderBy = stmt.orderBy.map((o) => ({
|
||||||
|
...o,
|
||||||
|
column: unquoteIdentifier(this.stripAlias(o.column, list)),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
if (stmt.groupBy && stmt.groupBy.length > 0) {
|
if (stmt.groupBy && stmt.groupBy.length > 0) {
|
||||||
stmt.groupBy = stmt.groupBy.map((c) => this.stripAlias(c, list));
|
stmt.groupBy = stmt.groupBy.map((c) => unquoteIdentifier(this.stripAlias(c, list)));
|
||||||
}
|
}
|
||||||
stmt.columns = stmt.columns.map((c) => {
|
stmt.columns = stmt.columns.map((c) => {
|
||||||
if (c === '*' || isAggregateExpression(c) || /^\s*CASE\b/i.test(c) || /^'/.test(c)) return c;
|
if (c === '*' || isAggregateExpression(c) || /^\s*CASE\b/i.test(c) || /^'/.test(c)) return c;
|
||||||
@@ -2199,7 +2360,10 @@ export class QueryExecutor {
|
|||||||
normalized[key] = this.normalizeExistsValue(value, aliases);
|
normalized[key] = this.normalizeExistsValue(value, aliases);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const newKey = this.stripAlias(key, aliases);
|
// v0.8.0(B-5):WHERE 键脱去分隔标识符引号(`"1" = 'y'` → `1`),
|
||||||
|
// 否则 matchWhere 用裸 `1` 取值、而行键也是裸 `1`,两者因为引号不同
|
||||||
|
// 永远匹配不上 → 静默空结果(实测)。引号到此已完成"区分列 vs 常量"的使命。
|
||||||
|
const newKey = unquoteIdentifier(this.stripAlias(key, aliases));
|
||||||
normalized[newKey] = this.normalizeFieldValue(value, aliases);
|
normalized[newKey] = this.normalizeFieldValue(value, aliases);
|
||||||
}
|
}
|
||||||
return normalized;
|
return normalized;
|
||||||
@@ -2226,10 +2390,10 @@ export class QueryExecutor {
|
|||||||
} else if (op === '$not' && typeof operand === 'object' && operand !== null) {
|
} else if (op === '$not' && typeof operand === 'object' && operand !== null) {
|
||||||
ops[op] = this.normalizeFieldValue(operand, aliases);
|
ops[op] = this.normalizeFieldValue(operand, aliases);
|
||||||
} else if (op === '$col') {
|
} else if (op === '$col') {
|
||||||
ops[op] = this.stripAlias(String(operand), aliases);
|
ops[op] = unquoteIdentifier(this.stripAlias(String(operand), aliases));
|
||||||
} else if (typeof operand === 'object' && operand !== null && !Array.isArray(operand) && '$col' in (operand as Record<string, unknown>)) {
|
} else if (typeof operand === 'object' && operand !== null && !Array.isArray(operand) && '$col' in (operand as Record<string, unknown>)) {
|
||||||
// 操作符值中嵌套的列引用:{ $eq: { $col: 'u.id' } }
|
// 操作符值中嵌套的列引用:{ $eq: { $col: 'u.id' } }
|
||||||
ops[op] = { $col: this.stripAlias(String((operand as Record<string, unknown>).$col), aliases) };
|
ops[op] = { $col: unquoteIdentifier(this.stripAlias(String((operand as Record<string, unknown>).$col), aliases)) };
|
||||||
} else {
|
} else {
|
||||||
ops[op] = operand;
|
ops[op] = operand;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -464,19 +464,46 @@ function compare(a: unknown, b: unknown): number {
|
|||||||
// 列投影
|
// 列投影
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列投影。
|
||||||
|
*
|
||||||
|
* v0.8.0(B-5):支持**分隔标识符**(`"1"`)与 JOIN 行的 `别名.列` 键。
|
||||||
|
* 此前只做"精确命中,否则找 `endsWith('.col')`",于是:
|
||||||
|
* - `SELECT "1" FROM q`(列名就叫 1)取不到值 → 输出 `{}`
|
||||||
|
* (校验用裸名、投影用带引号的名,两套规则 —— 典型漂移);
|
||||||
|
* - 找不到时**不产出键**,行形状随列是否存在而变。
|
||||||
|
* 现在统一:脱引号 → 精确命中 → 唯一后缀命中;仍找不到则不产出该键
|
||||||
|
*(是否"未知列"由 executor 的 assertProjectionColumnsExist 判定并报错,
|
||||||
|
* 投影层不做静默兜底)。
|
||||||
|
*/
|
||||||
export function projectColumns(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
|
export function projectColumns(row: Record<string, unknown>, columns: string[]): Record<string, unknown> {
|
||||||
const projected: Record<string, unknown> = {};
|
const projected: Record<string, unknown> = {};
|
||||||
for (const col of columns) {
|
for (const col of columns) {
|
||||||
if (col in row) {
|
const name = unquoteIdentifier(col);
|
||||||
projected[col] = row[col];
|
if (name in row) {
|
||||||
} else {
|
projected[name] = row[name];
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// JOIN 行键形如 `t.col`:唯一后缀匹配(多个命中视为歧义,取第一个与
|
||||||
|
// executor 的校验口径一致 —— 那里已对歧义报错,能走到这里说明唯一)
|
||||||
|
let found: unknown;
|
||||||
|
let hits = 0;
|
||||||
for (const key of Object.keys(row)) {
|
for (const key of Object.keys(row)) {
|
||||||
if (key.endsWith(`.${col}`) || key === col) {
|
if (key.endsWith(`.${name}`) || key === name) {
|
||||||
projected[col] = row[key];
|
found = row[key];
|
||||||
break;
|
hits += 1;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (hits >= 1) projected[name] = found;
|
||||||
}
|
}
|
||||||
return projected;
|
return projected;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 脱去分隔标识符的引号(`"1"` → `1`,`"a""b"` → `a"b`) */
|
||||||
|
function unquoteIdentifier(text: string): string {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) {
|
||||||
|
return trimmed.slice(1, -1).replace(/""/g, '"');
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|||||||
+57
-2
@@ -354,7 +354,7 @@ export class Parser {
|
|||||||
if (this.curTokenIs(TokenType.GROUP)) {
|
if (this.curTokenIs(TokenType.GROUP)) {
|
||||||
this.nextToken();
|
this.nextToken();
|
||||||
this.expect(TokenType.BY);
|
this.expect(TokenType.BY);
|
||||||
stmt.groupBy = this.parseIdentifierList();
|
stmt.groupBy = this.parseGroupByList();
|
||||||
}
|
}
|
||||||
|
|
||||||
// HAVING(可选)
|
// HAVING(可选)
|
||||||
@@ -1221,6 +1221,25 @@ export class Parser {
|
|||||||
return this.parseAggregateCall();
|
return this.parseAggregateCall();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-5):分隔标识符必须**保留引号**。
|
||||||
|
*
|
||||||
|
* 此前 `SELECT "1" FROM q`(列名就叫 `1`,建表时用引号声明)被解析成裸 `1`,
|
||||||
|
* 而投影阶段把裸数字当**常量**列 → 返回 `{"1": 1}`(字面量 1),
|
||||||
|
* 而 `SELECT *` 返回正确的 `{"1": "z"}` —— 同一列两种结论(实测)。
|
||||||
|
* 保留引号后,下游能区分"名为 1 的列"与"常量 1"。
|
||||||
|
*/
|
||||||
|
if (this.curTokenIs(TokenType.QUOTED_IDENTIFIER)) {
|
||||||
|
const name = this.curToken.value;
|
||||||
|
this.nextToken();
|
||||||
|
if (this.curTokenIs(TokenType.DOT)) {
|
||||||
|
this.nextToken();
|
||||||
|
const second = this.expectIdentifier('column name after "."');
|
||||||
|
return `"${name.replace(/"/g, '""')}".${second}`;
|
||||||
|
}
|
||||||
|
return `"${name.replace(/"/g, '""')}"`;
|
||||||
|
}
|
||||||
|
|
||||||
const first = this.expectIdentifier('column name');
|
const first = this.expectIdentifier('column name');
|
||||||
if (this.curTokenIs(TokenType.DOT)) {
|
if (this.curTokenIs(TokenType.DOT)) {
|
||||||
this.nextToken();
|
this.nextToken();
|
||||||
@@ -1328,6 +1347,42 @@ export class Parser {
|
|||||||
return vals;
|
return vals;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v0.8.0(B-5):ORDER BY / GROUP BY 的键可以是**输出列序号**(SQL 标准)。
|
||||||
|
*
|
||||||
|
* `ORDER BY 1` 表示"按第 1 个输出列排序",`GROUP BY 2` 同理 ——
|
||||||
|
* 这在手写 SQL 与 UNION 里非常常用(各分支输出列名可能不同,只能按序号引用)。
|
||||||
|
*
|
||||||
|
* 此前 parser 的 `parseIdentifierWithDot` 只接受标识符,于是 `ORDER BY 1`
|
||||||
|
* 直接 `PARSE_ERROR: Expected identifier, got "1"`(实测)。
|
||||||
|
*
|
||||||
|
* 这里把序号**原样保留为数字字符串**(AST 形状不变),由 executor 在拿到
|
||||||
|
* SELECT 列表后再解析成对应表达式 —— 序号的含义依赖 SELECT 列表,
|
||||||
|
* parser 层无从判断。
|
||||||
|
*/
|
||||||
|
private parseOrderOrGroupKey(): string {
|
||||||
|
if (this.curTokenIs(TokenType.NUMBER)) {
|
||||||
|
const value = this.curToken.value;
|
||||||
|
// 序号必须是正整数(`ORDER BY 0` / `ORDER BY 1.5` 非法)
|
||||||
|
if (!/^\d+$/.test(value) || Number(value) < 1) {
|
||||||
|
throw this.error(`Invalid output column ordinal "${value}" (must be a positive integer)`);
|
||||||
|
}
|
||||||
|
this.nextToken();
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return this.parseIdentifierWithDot();
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseGroupByList(): string[] {
|
||||||
|
const list: string[] = [];
|
||||||
|
list.push(this.parseOrderOrGroupKey());
|
||||||
|
while (this.curTokenIs(TokenType.COMMA)) {
|
||||||
|
this.nextToken();
|
||||||
|
list.push(this.parseOrderOrGroupKey());
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
private parseOrderByList(): OrderBy[] {
|
private parseOrderByList(): OrderBy[] {
|
||||||
const list: OrderBy[] = [];
|
const list: OrderBy[] = [];
|
||||||
list.push(this.parseOrderBy());
|
list.push(this.parseOrderBy());
|
||||||
@@ -1339,7 +1394,7 @@ export class Parser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private parseOrderBy(): OrderBy {
|
private parseOrderBy(): OrderBy {
|
||||||
const column = this.parseIdentifierWithDot();
|
const column = this.parseOrderOrGroupKey();
|
||||||
let direction: SortDirection = 'asc';
|
let direction: SortDirection = 'asc';
|
||||||
if (this.curTokenIs(TokenType.ASC)) {
|
if (this.curTokenIs(TokenType.ASC)) {
|
||||||
this.nextToken();
|
this.nextToken();
|
||||||
|
|||||||
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* v0.8.0 回归套件 —— B-5 输出列序号与分隔标识符
|
||||||
|
* ============================================================================
|
||||||
|
* 两个相关缺陷:
|
||||||
|
*
|
||||||
|
* ① **输出列序号不被支持**(SQL 标准特性)
|
||||||
|
* `ORDER BY 1` / `GROUP BY 2` 此前直接
|
||||||
|
* `PARSE_ERROR: Expected identifier, got "1"`(实测)。
|
||||||
|
* 这在手写 SQL 与 UNION 里很常用 —— 各分支输出列名可能不同,只能按序号引用。
|
||||||
|
* 序号的含义依赖 SELECT 列表,因此 parser 只保留数字文本,
|
||||||
|
* 由 executor 在拿到 SELECT 列表后解析。
|
||||||
|
*
|
||||||
|
* ② **分隔标识符(`"1"`)在投影期被当成常量**
|
||||||
|
* `CREATE TABLE q ("1" STRING ...)` 后 `SELECT "1" FROM q` 返回
|
||||||
|
* `{"1": 1}`(**字面量 1**),而 `SELECT *` 返回正确的 `{"1": "z"}`
|
||||||
|
* —— 同一列两种结论(实测)。根因是 parser 把 `"1"` 的引号丢掉,
|
||||||
|
* 投影期的裸数字常量子句就把它吃了。
|
||||||
|
*
|
||||||
|
* 修复涉及的关键设计:**引号只在"解析 → 执行"的边界脱去**,
|
||||||
|
* 因为 parser 必须保留引号才能区分"名为 1 的列"(`"1"`)与"常量 1"(`1`);
|
||||||
|
* 而校验/取值必须用真实列名。此前"校验用裸名、投影用带引号的名"的两套规则
|
||||||
|
* 正是漂移的来源(实测出现 `1` 与 `"1"` 两个键并存)。
|
||||||
|
*
|
||||||
|
* 序号优先级(与 SQL 标准的名称解析顺序一致):
|
||||||
|
* 真实列名 > 输出列别名 > 位置序号。
|
||||||
|
* `SELECT "1", other FROM q ORDER BY 1` → 按**名为 1 的列**排序。
|
||||||
|
*/
|
||||||
|
import { describe, it, expect, beforeEach } from '@jest/globals';
|
||||||
|
import { MetonaSqlark } from '../src/core';
|
||||||
|
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' }],
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('[v0.8.0] B-5 输出列序号(四引擎)', () => {
|
||||||
|
describe.each(ENGINES)('%s 引擎', (label, mode, extra) => {
|
||||||
|
let db: MetonaSqlark;
|
||||||
|
let seq = 0;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
seq += 1;
|
||||||
|
db = await MetonaSqlark.create({
|
||||||
|
name: `b5-${label}-${seq}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
mode,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
await db.defineTable('t', {
|
||||||
|
id: { type: 'string', primaryKey: true },
|
||||||
|
g: { type: 'string' },
|
||||||
|
n: { type: 'number' },
|
||||||
|
});
|
||||||
|
await db.query("INSERT INTO t VALUES ('1','a',30),('2','a',10),('3','b',20)");
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ORDER BY 1 / 2 按输出列位置排序', async () => {
|
||||||
|
const byFirst = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT id, n FROM t ORDER BY 1'),
|
||||||
|
);
|
||||||
|
expect(byFirst.map((r) => r.id)).toEqual(['1', '2', '3']);
|
||||||
|
const bySecond = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT id, n FROM t ORDER BY 2'),
|
||||||
|
);
|
||||||
|
expect(bySecond.map((r) => r.n)).toEqual([10, 20, 30]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ORDER BY 序号支持 DESC 与多键', async () => {
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT n, id FROM t ORDER BY 2 DESC, 1'),
|
||||||
|
);
|
||||||
|
expect(rows.map((r) => r.id)).toEqual(['3', '2', '1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ORDER BY 序号可与 LIMIT/OFFSET 组合', async () => {
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT id FROM t ORDER BY 1 DESC LIMIT 2 OFFSET 1'),
|
||||||
|
);
|
||||||
|
expect(rows.map((r) => r.id)).toEqual(['2', '1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GROUP BY 序号按输出列分组', async () => {
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT g, COUNT(*) AS c FROM t GROUP BY 1 ORDER BY 1'),
|
||||||
|
);
|
||||||
|
expect(rows).toEqual([{ g: 'a', c: 2 }, { g: 'b', c: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GROUP BY 序号指向聚合表达式 → QUERY_ERROR', async () => {
|
||||||
|
// 按聚合值分组语义上不成立(SQL 标准同样禁止)
|
||||||
|
await expect(db.query('SELECT g, COUNT(*) AS c FROM t GROUP BY 2')).rejects.toMatchObject({
|
||||||
|
code: 'QUERY_ERROR',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('序号越界 → QUERY_ERROR(而不是"未知列")', async () => {
|
||||||
|
await expect(db.query('SELECT id FROM t ORDER BY 3')).rejects.toMatchObject({
|
||||||
|
code: 'QUERY_ERROR',
|
||||||
|
});
|
||||||
|
await expect(db.query('SELECT id FROM t GROUP BY 3')).rejects.toMatchObject({
|
||||||
|
code: 'QUERY_ERROR',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ORDER BY 0 / 非整数序号 → PARSE_ERROR', async () => {
|
||||||
|
await expect(db.query('SELECT id FROM t ORDER BY 0')).rejects.toMatchObject({
|
||||||
|
code: 'PARSE_ERROR',
|
||||||
|
});
|
||||||
|
await expect(db.query('SELECT id FROM t ORDER BY 1.5')).rejects.toMatchObject({
|
||||||
|
code: 'PARSE_ERROR',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('列名与别名引用不受影响(回归护栏)', async () => {
|
||||||
|
expect(
|
||||||
|
rowsOf<Record<string, unknown>>(await db.query('SELECT n AS num FROM t ORDER BY num DESC'))
|
||||||
|
.map((r) => r.num),
|
||||||
|
).toEqual([30, 20, 10]);
|
||||||
|
expect(
|
||||||
|
rowsOf<Record<string, unknown>>(await db.query('SELECT n AS num FROM t ORDER BY n DESC'))
|
||||||
|
.map((r) => r.num),
|
||||||
|
).toEqual([30, 20, 10]);
|
||||||
|
expect(
|
||||||
|
rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT g AS grp, COUNT(*) AS c FROM t GROUP BY grp ORDER BY grp'),
|
||||||
|
),
|
||||||
|
).toEqual([{ g: 'a', c: 2 }, { g: 'b', c: 1 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('UNION 后的 ORDER BY 序号作用于复合结果', async () => {
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT id FROM t UNION SELECT id FROM t ORDER BY 1 DESC LIMIT 2'),
|
||||||
|
);
|
||||||
|
expect(rows.map((r) => r.id)).toEqual(['3', '2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('序号引用带别名的 CASE 输出列', async () => {
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query(
|
||||||
|
"SELECT id, CASE WHEN n > 15 THEN 'big' ELSE 'small' END AS band FROM t ORDER BY 1",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(rows.map((r) => r.id)).toEqual(['1', '2', '3']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('[v0.8.0] B-5 分隔标识符(四引擎)', () => {
|
||||||
|
describe.each(ENGINES)('%s 引擎', (label, mode, extra) => {
|
||||||
|
let db: MetonaSqlark;
|
||||||
|
let seq = 100;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
seq += 1;
|
||||||
|
db = await MetonaSqlark.create({
|
||||||
|
name: `b5q-${label}-${seq}-${Math.random().toString(36).slice(2)}`,
|
||||||
|
mode,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
await db.query('CREATE TABLE q ("1" STRING PRIMARY KEY, other NUMBER)');
|
||||||
|
await db.query(`INSERT INTO q ("1", other) VALUES ('z', 1), ('y', 2), ('x', 3)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await db.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SELECT "1" 返回列值而非常量(修复前返回字面量 1)', async () => {
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(await db.query('SELECT "1", other FROM q'));
|
||||||
|
expect(rows).toHaveLength(3);
|
||||||
|
// 值与 `SELECT *` 一致
|
||||||
|
const star = rowsOf<Record<string, unknown>>(await db.query('SELECT * FROM q'));
|
||||||
|
expect(rows.map((r) => r['1']).sort()).toEqual(star.map((r) => r['1']).sort());
|
||||||
|
expect(rows.map((r) => r['1']).sort()).toEqual(['x', 'y', 'z']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('SELECT "1" 的输出键与 SELECT * 一致(不出现重复键)', async () => {
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(await db.query('SELECT "1", other FROM q'));
|
||||||
|
expect(Object.keys(rows[0]).sort()).toEqual(['1', 'other']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('WHERE 引用分隔标识符', async () => {
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query(`SELECT other FROM q WHERE "1" = 'y'`),
|
||||||
|
);
|
||||||
|
expect(rows).toEqual([{ other: 2 }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ORDER BY 优先解析为真实列名(序号简写让位于显式名字)', async () => {
|
||||||
|
// 列名就是 "1" → `ORDER BY 1` 按该列排序(升序 x,y,z)
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(await db.query('SELECT "1", other FROM q ORDER BY 1'));
|
||||||
|
expect(rows.map((r) => r['1'])).toEqual(['x', 'y', 'z']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GROUP BY 分隔标识符且输出键唯一', async () => {
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(
|
||||||
|
await db.query('SELECT "1", COUNT(*) AS c FROM q GROUP BY 1 ORDER BY 1'),
|
||||||
|
);
|
||||||
|
expect(rows).toEqual([
|
||||||
|
{ '1': 'x', c: 1 },
|
||||||
|
{ '1': 'y', c: 1 },
|
||||||
|
{ '1': 'z', c: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('表里无同名列时 ORDER BY 1 仍是序号', async () => {
|
||||||
|
// 换成没有数字名列表,序号才无歧义:`ORDER BY 1 DESC` 按 other 降序
|
||||||
|
const rows = rowsOf<Record<string, unknown>>(await db.query('SELECT other FROM q ORDER BY 1 DESC'));
|
||||||
|
expect(rows.map((r) => r.other)).toEqual([3, 2, 1]);
|
||||||
|
// 对照:写裸常量 1 时它才是常量列(每行都是 1),排序不改变结果
|
||||||
|
const literal = rowsOf<Record<string, unknown>>(await db.query('SELECT 1 FROM q ORDER BY 1'));
|
||||||
|
expect(literal.map((r) => r['1'])).toEqual([1, 1, 1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user