Files
MetonaSqlark/tests/v080-query-layer.test.ts
T
thzxx 50b1864145 chore(docs): 删除 v0.7.4 审计与 v0.7.5 计划共 4 个 md 文档
删除:
- AUDIT-aria-lsm-v0.7.4.md(50KB)
- AUDIT-query-layer-v0.7.4.md(39KB)
- AUDIT-storage-engines-v0.7.4.md(39KB)
- PLAN-v0.7.5.md(98KB,含附录 G/H/I)

删除前先清除引用面,避免留下断链(共 18 处):
- 源码注释 7 处(change-notifier / kvstore index / column-value / expression /
  sql-compare / where-matcher / validation):保留设计意图,引用改为"v0.8.0 审计根因 N"
- 测试注释 9 处(opfs.spec / aria-opfs-backend / faulty-backend / storage-harness /
  v080-b6 / v080-kvstore / v080-query-layer / v080-sql-three-valued /
  v080-unified-validation / parser):同上
- CHANGELOG 3 处:改为不依赖已删除文档的自洽表述(B-6 交付物见各条;门禁订正三处
  按内容重写),并把变异数量同步为 42
- 校验:三个 md 之间无断链;仓库内已无 PLAN-v0.7.5/AUDIT-* 的任何引用
  (git 历史仍可追溯,需要时可 `git show <commit>:PLAN-v0.7.5.md` 找回)

验证:93 套件 / 1985 用例全绿;覆盖率 90.59 / 82.61 / 94.14 / 93.50(阈值 90/82/94/93);
e2e 14/14;lint + 两份 tsc 干净;dist 已重建(注释只影响非压缩产物,min 产物
251,731 B / gzip 63,431 B 不变)。

说明:审查记录的核心内容仍在 CHANGELOG.md("全量回归审查"与"现场失败修复"两节),
随 PLAN 一起删除的是附录 G/H/I 的详细表格(门禁逐条验收、交付物清单、未修复项表)。
2026-09-15 17:17:24 +08:00

364 lines
18 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 回归套件 —— 查询层缺陷根治(A22/A23/A25/A26/A27/A29/A30/A36
* ============================================================================
* 本套件锁定 v0.8.0 迭代 §5 缺陷总账中查询层的 8 项修复。每一项都先给出
* **修复前的实测错误输出**,再断言正确结果 —— 这样即使将来重构执行器,
* 失败的断言能直接告诉后来者"当初错在哪里"。
*
* 参考数据(表 t):
* id=1 g='a' n=10 id=3 g='b' n=30
* id=2 g='a' n=20 id=4 g='b' n=40
*/
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] 查询层缺陷根治', () => {
describe.each(ENGINES)('%s 引擎', (label, mode, extra) => {
let db: MetonaSqlark;
beforeEach(async () => {
db = await MetonaSqlark.create({
name: `ql-${label}-${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',10),('2','a',20),('3','b',30),('4','b',40)");
});
afterEach(async () => {
await db.close();
});
// -------------------------------------------------------------------
// A22: GROUP BY 可引用 SELECT 别名
// -------------------------------------------------------------------
it('A22 GROUP BY 引用 SELECT 别名', async () => {
// 修复前:抛 `COLUMN_NOT_FOUND Unknown column "g" in SELECT list`
// GROUP BY grp 取 row['grp'] 得 undefined → 全部行并成一组 →
// 投影阶段又发现 g 不在输出行里,报出一个和真正原因无关的错误)
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT g AS grp, COUNT(*) AS c FROM t GROUP BY grp ORDER BY grp'),
);
expect(rows).toEqual([{ g: 'a', c: 2 }, { g: 'b', c: 2 }]);
// 输出键与"按基列分组"完全一致(同一查询的两种写法必须等价)
const byBase = rowsOf<Record<string, unknown>>(
await db.query('SELECT g, COUNT(*) AS c FROM t GROUP BY g ORDER BY g'),
);
expect(rows).toEqual(byBase);
});
it('A22 GROUP BY 引用别名时不泄漏别名键', async () => {
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT g AS grp FROM t GROUP BY grp ORDER BY grp'),
);
// 输出的键必须是基列名 gSELECT 列表里 g AS grp 的投影结果),
// 而不是内部的分组键 grp —— 否则同一查询在"有别名/无别名"两种写法下
// 行形状不同。
expect(rows).toEqual([{ g: 'a' }, { g: 'b' }]);
});
// -------------------------------------------------------------------
// A23: HAVING 可引用未出现在 SELECT 里的聚合
// -------------------------------------------------------------------
it('A23 HAVING 引用未选中的聚合(SUM', async () => {
// 修复前:返回 []SUM(n) 从未被求值 → HAVING 的键在行里不存在 → UNKNOWN)
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT g FROM t GROUP BY g HAVING SUM(n) > 25 ORDER BY g'),
);
expect(rows).toEqual([{ g: 'a' }, { g: 'b' }]);
});
it('A23 HAVING 引用未选中的聚合(MAX)并正确过滤', async () => {
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT g FROM t GROUP BY g HAVING MAX(n) > 25 ORDER BY g'),
);
expect(rows).toEqual([{ g: 'b' }]);
});
it('A23 HAVING 的辅助聚合不得出现在输出行里', async () => {
// 为 HAVING 计算的 SUM(n) 是内部键:输出必须只有 g 一列。
// (修复过程中曾泄漏成 {g, 'SUM(n)'} —— 多出用户没要求的输出列。)
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT g FROM t GROUP BY g HAVING SUM(n) > 0 ORDER BY g'),
);
expect(rows).toEqual([{ g: 'a' }, { g: 'b' }]);
expect(Object.keys(rows[0])).toEqual(['g']);
});
it('A23 HAVING 同时引用别名与未选中聚合', async () => {
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT g AS grp, COUNT(*) AS c FROM t GROUP BY grp HAVING SUM(n) > 25 ORDER BY grp'),
);
expect(rows).toEqual([{ g: 'a', c: 2 }, { g: 'b', c: 2 }]);
});
// -------------------------------------------------------------------
// A25: 带表前缀的聚合参数
// -------------------------------------------------------------------
it('A25 COUNT(t.n) / SUM(t.n) 按列取值而非恒 0', async () => {
// 修复前:COUNT(t.n) → 0SUM(t.n) → null(行键是 n,直接取 row['t.n'] 得
// undefined,再被"过滤 NULL"剔除,且**不报错**
expect(rowsOf(await db.query('SELECT COUNT(t.n) AS c FROM t'))).toEqual([{ c: 4 }]);
expect(rowsOf(await db.query('SELECT SUM(t.n) AS s FROM t'))).toEqual([{ s: 100 }]);
expect(rowsOf(await db.query('SELECT AVG(t.n) AS a FROM t'))).toEqual([{ a: 25 }]);
expect(rowsOf(await db.query('SELECT MIN(t.n) AS lo FROM t'))).toEqual([{ lo: 10 }]);
expect(rowsOf(await db.query('SELECT MAX(t.n) AS hi FROM t'))).toEqual([{ hi: 40 }]);
});
it('A25 分组聚合的带前缀参数', async () => {
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT g, SUM(t.n) AS s FROM t GROUP BY g ORDER BY g'),
);
expect(rows).toEqual([{ g: 'a', s: 30 }, { g: 'b', s: 70 }]);
});
it('A25 COUNT(DISTINCT t.n) 带前缀且类型安全去重', async () => {
expect(rowsOf(await db.query('SELECT COUNT(DISTINCT t.n) AS c FROM t'))).toEqual([{ c: 4 }]);
// 数值去重不得被编码串扰(曾把 encodeValueKey 的结果 Number() 回读成 NaN
expect(rowsOf(await db.query('SELECT SUM(DISTINCT t.n) AS s FROM t'))).toEqual([{ s: 100 }]);
});
it('A25 聚合参数引用不存在的列 → COLUMN_NOT_FOUND(不静默计 0', async () => {
await expect(db.query('SELECT COUNT(t.nope) AS c FROM t')).rejects.toMatchObject({
code: 'COLUMN_NOT_FOUND',
});
});
// -------------------------------------------------------------------
// A26: UNION 尾部 ORDER BY / LIMIT 作用于整个复合结果
// -------------------------------------------------------------------
it('A26 UNION 尾部 ORDER BY 作用于复合结果', async () => {
// 修复前:只对右侧 SELECT 排序 → [{1},{2},{4},{3}]
const rows = rowsOf<Record<string, unknown>>(
await db.query("SELECT id FROM t WHERE g = 'a' UNION SELECT id FROM t WHERE g = 'b' ORDER BY id DESC"),
);
expect(rows).toEqual([{ id: '4' }, { id: '3' }, { id: '2' }, { id: '1' }]);
});
it('A26 UNION 尾部 LIMIT 作用于复合结果', async () => {
// 修复前:只截断右侧 → 返回 4 行
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT id FROM t UNION SELECT id FROM t LIMIT 3'),
);
expect(rows).toHaveLength(3);
});
it('A26 UNION LIMIT 只应用一次(不得双重截断)', async () => {
const limited = rowsOf<Record<string, unknown>>(
await db.query('SELECT id FROM t UNION ALL SELECT id FROM t LIMIT 5'),
);
expect(limited).toHaveLength(5);
});
it('A26 UNION 去重 + OFFSET', async () => {
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT id FROM t UNION SELECT id FROM t ORDER BY id LIMIT 2 OFFSET 1'),
);
expect(rows).toEqual([{ id: '2' }, { id: '3' }]);
});
it('A26 UNION ORDER BY 引用不存在的列 → COLUMN_NOT_FOUND', async () => {
await expect(db.query('SELECT id FROM t UNION SELECT id FROM t ORDER BY nope')).rejects.toMatchObject({
code: 'COLUMN_NOT_FOUND',
});
});
// -------------------------------------------------------------------
// A27: DISTINCT 作用于输出列(投影之后)
// -------------------------------------------------------------------
it('A27 DISTINCT 列别名与裸列结果一致', async () => {
// 修复前:`SELECT DISTINCT g AS d` 返回 4 行 a,a,b,b
//DISTINCT 作用在投影前的 {id,g,n} 原始行上,四行互不相同)
const aliased = rowsOf<Record<string, unknown>>(await db.query('SELECT DISTINCT g AS d FROM t ORDER BY d'));
expect(aliased).toEqual([{ d: 'a' }, { d: 'b' }]);
const bare = rowsOf<Record<string, unknown>>(await db.query('SELECT DISTINCT g FROM t ORDER BY g'));
expect(bare).toEqual([{ g: 'a' }, { g: 'b' }]);
});
it('A27 DISTINCT 多列仍按输出列去重', async () => {
const rows = rowsOf<Record<string, unknown>>(await db.query('SELECT DISTINCT g, n FROM t ORDER BY g, n'));
expect(rows).toHaveLength(4);
});
it('A27 DISTINCT + ORDER BY 输出列(先去重再排序)', async () => {
const rows = rowsOf<Record<string, unknown>>(await db.query('SELECT DISTINCT g FROM t ORDER BY g DESC'));
expect(rows).toEqual([{ g: 'b' }, { g: 'a' }]);
});
// -------------------------------------------------------------------
// A30: INSERT 值个数与列个数不匹配 → 报错(此前静默丢弃多余值)
// -------------------------------------------------------------------
it('A30 显式列名时值多于列 → PARSE_ERROR(解析期即可判定)', async () => {
// 修复前:`INSERT INTO t (id, g) VALUES ('9','z','LOST')` 报成功、'LOST' 消失。
// 有了显式列名后 arity 无需 schema 即可判定,因此解析期就拦下
//v0.8.0 A16 的解析期校验;executor 侧对"未显式给列名"的情形兜底)。
await expect(
db.query("INSERT INTO t (id, g) VALUES ('9', 'z', 'LOST')"),
).rejects.toMatchObject({ code: 'PARSE_ERROR' });
expect(rowsOf(await db.query("SELECT id FROM t WHERE id = '9'"))).toHaveLength(0);
});
it('A30 显式列名时值少于列 → PARSE_ERROR(缺列必须显式写出)', async () => {
// `INSERT INTO t (id, g) VALUES ('9')` 是列/值个数不匹配的写法:
// 用户想写的是 `INSERT INTO t (id) VALUES ('9')`。静默补 NULL 会让
// 拼错列清单的语句"看起来成功",因此同样报错。
await expect(db.query("INSERT INTO t (id, g) VALUES ('9')")).rejects.toMatchObject({
code: 'PARSE_ERROR',
});
});
it('A30 未显式列名时值多于列 → VALIDATION_ERROR(需要 schema 才能判定)', async () => {
// 不带列清单时个数要对照 schema 才能判断,由 executor 在拿到 schema 后校验
await expect(db.query("INSERT INTO t VALUES ('9', 'z', 1, 'LOST')")).rejects.toMatchObject({
code: 'VALIDATION_ERROR',
});
expect(rowsOf(await db.query("SELECT id FROM t WHERE id = '9'"))).toHaveLength(0);
});
it('A30 值少于列仍合法(缺列走 default / NULL', async () => {
await db.query("INSERT INTO t (id, g) VALUES ('9', 'z')");
const rows = rowsOf<Record<string, unknown>>(await db.query("SELECT id, g, n FROM t WHERE id = '9'"));
expect(rows).toEqual([{ id: '9', g: 'z', n: null }]);
});
// -------------------------------------------------------------------
// A36: 派生表别名引用
// -------------------------------------------------------------------
it('A36 派生表别名引用与裸列引用结果一致', async () => {
// 修复前:`SELECT d.id FROM (SELECT ...) AS d` 返回 []d.id 未剥离前缀),
// 而同义的 `SELECT id FROM (...) AS d` 正确
const aliased = rowsOf<Record<string, unknown>>(
await db.query("SELECT d.id FROM (SELECT id, g FROM t) AS d WHERE d.g = 'a' ORDER BY d.id"),
);
expect(aliased).toEqual([{ id: '1' }, { id: '2' }]);
const bare = rowsOf<Record<string, unknown>>(
await db.query("SELECT id FROM (SELECT id, g FROM t) AS d WHERE g = 'a' ORDER BY id"),
);
expect(aliased).toEqual(bare);
});
it('A36 派生表别名用于聚合与排序', async () => {
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT COUNT(d.id) AS c FROM (SELECT id FROM t) AS d'),
);
expect(rows).toEqual([{ c: 4 }]);
});
// -------------------------------------------------------------------
// A35: JOIN 的 NULL 键
// -------------------------------------------------------------------
it('A35 JOIN 的 NULL 键不成立,且与右表有无索引无关', async () => {
await db.defineTable('l', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
await db.defineTable('ri', { id: { type: 'string', primaryKey: true }, k: { type: 'string', index: true } });
await db.defineTable('rn', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
await db.query("INSERT INTO l VALUES ('l1','x'),('l2',NULL)");
await db.query("INSERT INTO ri VALUES ('r1','x'),('r2',NULL)");
await db.query("INSERT INTO rn VALUES ('n1','x'),('n2',NULL)");
const indexed = rowsOf<Record<string, unknown>>(
await db.query('SELECT l.id AS lid, ri.id AS rid FROM l JOIN ri ON l.k = ri.k'),
);
const unindexed = rowsOf<Record<string, unknown>>(
await db.query('SELECT l.id AS lid, rn.id AS rid FROM l JOIN rn ON l.k = rn.k'),
);
// NULL = NULL 是 UNKNOWN → 两个 NULL 行都不得匹配
expect(indexed).toEqual([{ lid: 'l1', rid: 'r1' }]);
expect(unindexed).toEqual([{ lid: 'l1', rid: 'n1' }]);
// 关键不变量:结果与"右表该列有没有索引"无关
expect(indexed.map((r) => r.lid)).toEqual(unindexed.map((r) => r.lid));
});
it('A35 LEFT JOIN 保留未匹配行(NULL 键行保留、右表列补 NULL', async () => {
await db.defineTable('l', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
await db.defineTable('r', { id: { type: 'string', primaryKey: true }, k: { type: 'string' } });
await db.query("INSERT INTO l VALUES ('l1','x'),('l2',NULL)");
await db.query("INSERT INTO r VALUES ('r1','x')");
const rows = rowsOf<Record<string, unknown>>(
await db.query('SELECT l.id AS lid, r.id AS rid FROM l LEFT JOIN r ON l.k = r.k ORDER BY lid'),
);
expect(rows).toEqual([{ lid: 'l1', rid: 'r1' }, { lid: 'l2', rid: null }]);
});
});
});
// ---------------------------------------------------------------------------
// maxRowsPerQuery 与 NaN 落盘(与引擎无关,用 memory 验证即可)
// ---------------------------------------------------------------------------
describe('[v0.8.0] A29 写路径的行数上限保护', () => {
it('INSERT ... SELECT 超过 maxRowsPerQuery → 显式报错(不静默截断)', async () => {
const db = await MetonaSqlark.create({ name: 'a29-limit', mode: 'memory', maxRowsPerQuery: 2 });
await db.defineTable('src', { id: { type: 'string', primaryKey: true } });
await db.defineTable('dst', { id: { type: 'string', primaryKey: true } });
// 注意:每条 INSERT 都受上限约束,因此分 4 条语句各写 1 行来堆积 4 行源数据
for (const id of ['1', '2', '3', '4']) {
await db.query(`INSERT INTO src VALUES ('${id}')`);
}
expect(rowsOf<{ c: number }>(await db.query('SELECT COUNT(*) AS c FROM src'))).toEqual([{ c: 4 }]);
// 修复前:行源被 maxRowsPerQuery 静默截断为 2 行 → 只写入 2 行并报成功。
// 现在:行源不截断,写路径显式报错,dst 保持空。
await expect(db.query('INSERT INTO dst SELECT id FROM src')).rejects.toMatchObject({
code: 'QUERY_ERROR',
});
expect(rowsOf(await db.query('SELECT id FROM dst'))).toHaveLength(0);
await db.close();
});
it('INSERT ... SELECT 未超上限时正常写入全部行源', async () => {
const db = await MetonaSqlark.create({ name: 'a29-within', mode: 'memory', maxRowsPerQuery: 2 });
await db.defineTable('src', { id: { type: 'string', primaryKey: true } });
await db.defineTable('dst', { id: { type: 'string', primaryKey: true } });
await db.query("INSERT INTO src VALUES ('1'),('2')");
await db.query('INSERT INTO dst SELECT id FROM src');
expect(rowsOf(await db.query('SELECT id FROM dst ORDER BY id'))).toEqual([{ id: '1' }, { id: '2' }]);
await db.close();
});
it('INSERT ... VALUES 同样受上限保护', async () => {
const db = await MetonaSqlark.create({ name: 'a29-limit-values', mode: 'memory', maxRowsPerQuery: 2 });
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
await expect(db.query("INSERT INTO t VALUES ('1'),('2'),('3')")).rejects.toMatchObject({
code: 'QUERY_ERROR',
});
expect(rowsOf(await db.query('SELECT id FROM t'))).toHaveLength(0);
await db.close();
});
});
describe('[v0.8.0] NaN / Infinity 拒绝落盘(B-1 规范化契约)', () => {
const ENGINES2 = ENGINES;
it.each(ENGINES2)('%s: NaN 写入被拒绝(否则重启后变 null)', async (label, mode, extra) => {
const db = await MetonaSqlark.create({ name: `nan-${label}`, mode, ...extra });
await db.defineTable('t', {
id: { type: 'string', primaryKey: true },
v: { type: 'number' },
});
await expect(db.table('t').insert({ id: '1', v: Number.NaN } as never)).rejects.toMatchObject({
code: 'VALIDATION_ERROR',
});
await expect(db.table('t').insert({ id: '2', v: Number.POSITIVE_INFINITY } as never)).rejects.toMatchObject({
code: 'VALIDATION_ERROR',
});
// 正常数值不受影响
await db.table('t').insert({ id: '3', v: 1.5 } as never);
expect(rowsOf<{ v: number }>(await db.query('SELECT v FROM t'))).toEqual([{ v: 1.5 }]);
await db.close();
});
});