Files
MetonaSqlark/tests/v080-output-ordinals.test.ts
T
thzxx 2c945ee05a 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 已重建。
2026-09-15 07:39:03 +08:00

223 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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]);
});
});
});