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
+222
View File
@@ -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]);
});
});
});