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:
thzxx
2026-09-15 07:39:03 +08:00
parent 3983aae426
commit 2c945ee05a
13 changed files with 1301 additions and 84 deletions
+263 -20
View File
@@ -1010,23 +1010,49 @@
// ---------------------------------------------------------------------------
// 列投影
// ---------------------------------------------------------------------------
/**
* 列投影
*
* v0.8.0B-5支持**分隔标识符**`"1"` JOIN 行的 `别名.列`
* 此前只做"精确命中,否则找 `endsWith('.col')`"于是
* - `SELECT "1" FROM q`列名就叫 1取不到值 输出 `{}`
* 校验用裸名投影用带引号的名两套规则 典型漂移
* - 找不到时**不产出键**行形状随列是否存在而变
* 现在统一脱引号 精确命中 唯一后缀命中仍找不到则不产出该键
*是否"未知列" executor assertProjectionColumnsExist 判定并报错
* 投影层不做静默兜底
*/
function projectColumns(row, columns) {
const projected = {};
for (const col of columns) {
if (col in row) {
projected[col] = row[col];
const name = unquoteIdentifier$1(col);
if (name in row) {
projected[name] = row[name];
continue;
}
else {
for (const key of Object.keys(row)) {
if (key.endsWith(`.${col}`) || key === col) {
projected[col] = row[key];
break;
}
// JOIN 行键形如 `t.col`:唯一后缀匹配(多个命中视为歧义,取第一个与
// executor 的校验口径一致 —— 那里已对歧义报错,能走到这里说明唯一)
let found;
let hits = 0;
for (const key of Object.keys(row)) {
if (key.endsWith(`.${name}`) || key === name) {
found = row[key];
hits += 1;
}
}
if (hits >= 1)
projected[name] = found;
}
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
@@ -11927,7 +11953,7 @@
if (this.curTokenIs(TokenType.GROUP)) {
this.nextToken();
this.expect(TokenType.BY);
stmt.groupBy = this.parseIdentifierList();
stmt.groupBy = this.parseGroupByList();
}
// HAVING(可选)
if (this.curTokenIs(TokenType.HAVING)) {
@@ -12719,6 +12745,24 @@
this.curTokenIs(TokenType.MAX)) {
return this.parseAggregateCall();
}
/**
* v0.8.0B-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');
if (this.curTokenIs(TokenType.DOT)) {
this.nextToken();
@@ -12818,6 +12862,40 @@
}
return vals;
}
/**
* v0.8.0B-5ORDER 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() {
const list = [];
list.push(this.parseOrderBy());
@@ -12828,7 +12906,7 @@
return list;
}
parseOrderBy() {
const column = this.parseIdentifierWithDot();
const column = this.parseOrderOrGroupKey();
let direction = 'asc';
if (this.curTokenIs(TokenType.ASC)) {
this.nextToken();
@@ -13009,7 +13087,13 @@
* 静默取第一个正是"结果取决于表顺序"这类难查问题的来源
*/
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)
return row[text];
// 别名前缀(`t.n` → `n`):JOIN 行用 `alias.col` 作键,单表路径的键不带前缀
@@ -13574,7 +13658,29 @@
const caseExpr = parseCaseExpression(item);
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.0B-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) {
const text = source.trim();
@@ -13979,6 +14085,7 @@
// 此前这里**不做**归一化:`SELECT d.id FROM (SELECT id, g FROM t) AS d`
// 直接拿 `d.id` 去投影,行里只有 `id` → 静默返回 `[]`;
// 而同一查询写成 `SELECT id ...` 却正确 —— 同一行源两种写法结论相反。
await this.resolveOutputOrdinals(stmt);
this.normalizeUnprefixedReferences(stmt, [stmt.alias ?? stmt.from]);
if (stmt.where && Object.keys(stmt.where).length > 0) {
// 非 JOINWHERE 在 executor 端过滤(子查询结果不经引擎)
@@ -13996,6 +14103,10 @@
rows = await this.executeJoinSelect(stmt);
}
else {
// v0.8.0B-5):先把 ORDER BY / GROUP BY 的输出列序号解析为输出列名。
// 必须在归一化之前:序号 → 列名的映射依赖 SELECT 列表原文
//`SELECT u.n FROM t u ORDER BY 1` → `u.n`,归一化后再解析会丢前缀)。
await this.resolveOutputOrdinals(stmt);
// 非 JOIN 路径:行键不带别名前缀 → 统一归一化引用
const mainAliases = [stmt.alias ?? stmt.from].filter(Boolean);
this.normalizeUnprefixedReferences(stmt, mainAliases);
@@ -14420,7 +14531,7 @@
// → 用**别名** `band`,因为 projectGroupedRow 按别名索引;
// - 裸列 → 用列名。
const caseExpr = parseCaseExpression(col);
const key = caseExpr?.alias ?? col;
const key = caseExpr?.alias ?? unquoteIdentifier(col);
aggregated[key] = resolveGroupKeyValue(first, col);
}
// 先算聚合(含仅 HAVING 引用的),统一以 exprKey 与输出键写入
@@ -14520,7 +14631,9 @@
output[aliasMatch[2]] = aggregated[aliasMatch[2]];
continue;
}
const base = bareReference(colExpr);
// v0.8.0B-5):脱引号 —— `SELECT "1" ... GROUP BY "1"` 的输出键必须与
// 行里的真实列名一致,否则会同时出现 `1` 与 `"1"` 两个键(实测)。
const base = unquoteIdentifier(bareReference(colExpr));
output[base] = base in aggregated ? aggregated[base] : aggregated[colExpr];
}
return output;
@@ -15180,7 +15293,10 @@
const missing = [];
const ambiguous = [];
const check = (ref) => {
const text = ref.trim();
// v0.8.0B-5):校验用**真实列名** —— 分隔标识符(`"1"`)在 WHERE 里
// 同样是列引用,必须脱引号后再比对(此前会报 `Unknown column "\"1\""`
// —— 错误消息里带着引号,用户看不出问题在哪)。
const text = unquoteIdentifier(ref);
if (!text)
return;
if (available.has(text)) {
@@ -15326,6 +15442,9 @@
const aliasMatch = col.match(/^(.+?)\s+AS\s+\w+$/i);
if (aliasMatch)
reference = aliasMatch[1].trim();
// v0.8.0B-5):校验用的是**真实列名**,因此这里脱去分隔标识符的引号
//`"1"` → `1`);保留引号只在投影期用于区分"列 vs 常量"。
reference = unquoteIdentifier(reference);
// 常量列(字符串 / 数字 / 布尔 / NULL)
if (/^'.*'$/s.test(reference))
continue;
@@ -15394,6 +15513,15 @@
constCols.push({ key: col, value: resolveAliasSource(col, row) });
continue;
}
// v0.8.0B-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);
}
// v0.7.3: hasStar 时以原行全部列为基(projectColumns 仅投影 plain 列,不含 * 的其余列)
@@ -15452,6 +15580,111 @@
// ===================================================================
// 关联子查询 / 别名规范化(v0.3.0)
// ===================================================================
/**
* v0.8.0B-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) {
stmt.where = this.normalizeWhereColumns(stmt.where, list);
}
// v0.8.0B-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) {
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) {
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) => {
if (c === '*' || isAggregateExpression(c) || /^\s*CASE\b/i.test(c) || /^'/.test(c))
@@ -15503,7 +15743,10 @@
normalized[key] = this.normalizeExistsValue(value, aliases);
continue;
}
const newKey = this.stripAlias(key, aliases);
// v0.8.0B-5):WHERE 键脱去分隔标识符引号(`"1" = 'y'` → `1`),
// 否则 matchWhere 用裸 `1` 取值、而行键也是裸 `1`,两者因为引号不同
// 永远匹配不上 → 静默空结果(实测)。引号到此已完成"区分列 vs 常量"的使命。
const newKey = unquoteIdentifier(this.stripAlias(key, aliases));
normalized[newKey] = this.normalizeFieldValue(value, aliases);
}
return normalized;
@@ -15532,11 +15775,11 @@
ops[op] = this.normalizeFieldValue(operand, aliases);
}
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) {
// 操作符值中嵌套的列引用:{ $eq: { $col: 'u.id' } }
ops[op] = { $col: this.stripAlias(String(operand.$col), aliases) };
ops[op] = { $col: unquoteIdentifier(this.stripAlias(String(operand.$col), aliases)) };
}
else {
ops[op] = operand;