/** * 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]> = [ ['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>( 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>( 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>( await db.query('SELECT g AS grp FROM t GROUP BY grp ORDER BY grp'), ); // 输出的键必须是基列名 g(SELECT 列表里 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>( 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>( 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>( 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>( 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) → 0,SUM(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>( 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>( 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>( 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>( 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>( 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>(await db.query('SELECT DISTINCT g AS d FROM t ORDER BY d')); expect(aliased).toEqual([{ d: 'a' }, { d: 'b' }]); const bare = rowsOf>(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>(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>(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>(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>( 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>( 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>( 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>( await db.query('SELECT l.id AS lid, ri.id AS rid FROM l JOIN ri ON l.k = ri.k'), ); const unindexed = rowsOf>( 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>( 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(); }); });