Files
MetonaSqlark/tests/v080-single-pipeline.test.ts
thzxx b20d47bd93 feat(B-3): 单管线 —— QueryBuilder 只产出 AST,执行一律经 Executor
背景(PLAN-v0.7.5.md 根因 2/4):
三个 builder 的 execute() 各自执行写入/查询,与 SQL 路径构成**两条管线**:
  - SelectQueryBuilder:无 JOIN 时直接调 engine.find(只有 JOIN 才走 executor)
  - UpdateQueryBuilder / DeleteQueryBuilder:直接调 engine.update/delete

于是同一条语义在两条路径上规则各写一份,实测差异:
  - `db.table('t').select(['t.n'])` 行键保留 `t.n`,SQL 路径归一化为 `n`
  - `select(['nope'])` 静默产出 `[{},{},{}]`(引擎不校验列存在性)
  - 不受 maxRowsPerQuery 约束
  - UPDATE/DELETE 的 `$subquery`/`$col`/`$exists` 无人解析 → 引擎判 UNKNOWN
    → **静默影响 0 行**(引擎层此前为此加了"检测到未解析标记就抛 NOT_SUPPORTED"
    的防御 —— 那是把"管线缺失"暴露成用户错误,方向错了)

改动:
1. SelectQueryBuilder / UpdateQueryBuilder / DeleteQueryBuilder 的 execute()
   统一为 `executor.execute(toAST())`;构造函数不再接收 engine。
   删掉 `if (joins.length > 0 && executor)` 的分支 —— executor 自己会在安全时
   下推到引擎,不需要 builder 代劳。
2. 生命周期钩子(beforeUpdate/afterUpdate/beforeDelete/afterDelete + onWrite
   广播)改由 Table 以回调形式注入 builder,顺序与修复前一致
   (before → executor → onWrite → after)。回调接收**实际语句**,
   因此 beforeUpdate 的 `query.where` 不再是空对象 —— 修复前 builder 路径的
   钩子能拿到 where,现在仍然能(新增测试锁定)。
3. Table 新增 requireExecutor():拿不到执行器时**明确报错**,不再静默退化为
   "直接调引擎"。Transaction.table() 相应构造绑定同一引擎的 QueryExecutor
   (事务原子性仍由引擎的 begin/commit/rollback 提供)。
4. 删除引擎层 4 处 `containsUnresolvedSubqueries → NOT_SUPPORTED` 防御:
   写路径已不可能出现未解析标记(builder 与 SQL 都经 Executor),
   留着它会让后来者误以为"这里需要防御"。

验证:新增 tests/v080-single-pipeline.test.ts(TABLE API 与 SQL API 逐值等价,
4 引擎 × 12 项 + 跨引擎 1 项,共 57 断言);全量 84 套件 / 1646 测试通过;
typecheck(src+tests) 与 lint 零错误。
2026-09-15 00:15:41 +08:00

230 lines
10 KiB
TypeScript
Raw Permalink 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-3 单管线(SQL 只有一条执行路径)
* ============================================================================
* 修复前每个 QueryBuilder 的 `execute()` 都有自己的执行动作:
* - `SelectQueryBuilder`**无 JOIN 时直接调 `engine.find`**,只有 JOIN 走 executor
* - `UpdateQueryBuilder` / `DeleteQueryBuilder`:直接调 `engine.update/delete`。
*
* 于是 TABLE API`db.table('t').select()...`)与 SQL API`db.query()`)是两条管线,
* 规则各写一份。本套件锁定"两条入口必须逐值等价"这一不变量 —— 任何一侧将来
* 自行加逻辑(如自己处理投影、自己下推 LIMIT),下面的断言就会失败。
*/
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-3 单管线:TABLE API 与 SQL API 逐值等价', () => {
describe.each(ENGINES)('%s 引擎', (label, mode, extra) => {
let db: MetonaSqlark;
beforeEach(async () => {
db = await MetonaSqlark.create({
name: `b3-${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();
});
// -------------------------------------------------------------------
// SELECT:直通路径此前缺少的阶段
// -------------------------------------------------------------------
it('SELECT 带表前缀的列:两条入口输出键相同', async () => {
// 修复前 builder 直通 engine.find,行键保留 `t.n`SQL 路径归一化为 `n`
const viaBuilder = rowsOf<Record<string, unknown>>(
await db.table('t').select(['t.id', 't.n']).execute(),
);
const viaSql = rowsOf<Record<string, unknown>>(await db.query('SELECT t.id, t.n FROM t'));
expect(viaBuilder).toHaveLength(4);
expect(Object.keys(viaBuilder[0]).sort()).toEqual(Object.keys(viaSql[0]).sort());
expect(viaBuilder).toEqual(expect.arrayContaining(viaSql));
});
it('SELECT 列别名:两条入口一致', async () => {
const viaBuilder = rowsOf<Record<string, unknown>>(
await db.table('t').select(['id AS ident']).execute(),
);
const viaSql = rowsOf<Record<string, unknown>>(await db.query('SELECT id AS ident FROM t'));
expect(viaBuilder).toEqual(viaSql);
});
it('SELECT 未知列:两条入口都抛 COLUMN_NOT_FOUND', async () => {
// 修复前 builder 直通引擎:引擎不校验列是否存在 → 静默产出 `[{}, {}, {}, {}]`
await expect(db.table('t').select(['nope']).execute()).rejects.toMatchObject({
code: 'COLUMN_NOT_FOUND',
});
await expect(db.query('SELECT nope FROM t')).rejects.toMatchObject({
code: 'COLUMN_NOT_FOUND',
});
});
it('SELECT LIMIT/OFFSET:两条入口切片一致(不双重截断)', async () => {
const viaBuilder = rowsOf<Record<string, unknown>>(
await db.table('t').select(['id']).orderBy('id').limit(2).offset(1).execute(),
);
const viaSql = rowsOf<Record<string, unknown>>(
await db.query('SELECT id FROM t ORDER BY id LIMIT 2 OFFSET 1'),
);
expect(viaBuilder).toEqual([{ id: '2' }, { id: '3' }]);
expect(viaBuilder).toEqual(viaSql);
});
it('SELECT 常量列:两条入口一致', async () => {
const viaBuilder = rowsOf<Record<string, unknown>>(
await db.table('t').select(['id', '1 AS one']).limit(1).execute(),
);
expect(viaBuilder).toEqual([{ id: '1', one: 1 }]);
});
// -------------------------------------------------------------------
// UPDATE / DELETE:此前完全绕过 Executor
// -------------------------------------------------------------------
it('UPDATE 带真实子查询 WHERETABLE API 正确解析(此前静默 0 行)', async () => {
// 修复前:builder 把 `{ $subquery: ... }` 直接交给 engine.update,引擎层无人
// 解析 → matchWhere 判 UNKNOWN → **返回 0 且不报错**(用户以为没有匹配行)。
await db.defineTable('u', { id: { type: 'string', primaryKey: true } });
await db.query("INSERT INTO u VALUES ('1'),('2')");
const affected = await db
.table('t')
.update({ g: 'z' })
.where({ id: { $in: { $subquery: { type: 'SELECT', columns: ['id'], from: 'u', where: {} } } } as never })
.execute();
expect(affected).toBe(2);
const rows = rowsOf<Record<string, unknown>>(await db.query('SELECT id, g FROM t ORDER BY id'));
expect(rows).toEqual([
{ id: '1', g: 'z' },
{ id: '2', g: 'z' },
{ id: '3', g: 'b' },
{ id: '4', g: 'b' },
]);
});
it('DELETE 带真实子查询 WHERETABLE API 正确解析(此前静默 0 行)', async () => {
await db.defineTable('u', { id: { type: 'string', primaryKey: true } });
await db.query("INSERT INTO u VALUES ('1'),('2')");
const affected = await db
.table('t')
.delete()
.where({ id: { $in: { $subquery: { type: 'SELECT', columns: ['id'], from: 'u', where: {} } } } as never })
.execute();
expect(affected).toBe(2);
expect(rowsOf(await db.query('SELECT id FROM t ORDER BY id'))).toEqual([
{ id: '3' },
{ id: '4' },
]);
});
it('UPDATE 基础路径:两条入口影响行数一致', async () => {
const viaSql = await db.query("UPDATE t SET g = 'x' WHERE n > 15");
const sb = rowsOf(await db.query("SELECT id FROM t WHERE g = 'x' ORDER BY id"));
expect(viaSql).toBe(3);
expect(sb).toEqual([{ id: '2' }, { id: '3' }, { id: '4' }]);
const viaBuilder = await db.table('t').update({ g: 'y' }).where({ n: { $gt: 15 } }).execute();
expect(viaBuilder).toBe(3);
expect(rowsOf(await db.query("SELECT id FROM t WHERE g = 'y' ORDER BY id"))).toEqual(sb);
});
it('DELETE 基础路径:两条入口影响行数一致', async () => {
const viaBuilder = await db.table('t').delete().where({ n: { $gt: 15 } }).execute();
expect(viaBuilder).toBe(3);
// 剩余行按 SELECT id 投影(SQL 路径只返回 id —— 两条入口的投影也一致)
expect(rowsOf(await db.query('SELECT id FROM t'))).toEqual([{ id: '1' }]);
});
it('UPDATE 未知列:两条入口都抛 COLUMN_NOT_FOUND', async () => {
await expect(
db.table('t').update({ nope: 1 } as never).where({ id: '1' }).execute(),
).rejects.toMatchObject({ code: 'COLUMN_NOT_FOUND' });
});
it('UPDATE 违反 maxLength:两条入口都抛 VALIDATION_ERROR', async () => {
await db.defineTable('u', {
id: { type: 'string', primaryKey: true },
name: { type: 'string', maxLength: 3 },
});
await db.query("INSERT INTO u VALUES ('1', 'abc')");
await expect(
db.table('u').update({ name: 'abcd' } as never).where({ id: '1' }).execute(),
).rejects.toMatchObject({ code: 'VALIDATION_ERROR' });
});
// -------------------------------------------------------------------
// 生命周期钩子仍在(且拿到真实 where)
// -------------------------------------------------------------------
it('TABLE API 写操作的钩子仍触发,且 before 拿到真实 where', async () => {
const seen: string[] = [];
db.on('beforeUpdate', (query) => {
seen.push(`before:${JSON.stringify((query as { where: unknown }).where)}`);
});
db.on('afterUpdate', (_query, _updates, count) => { seen.push(`after:${String(count)}`); });
await db.table('t').update({ g: 'q' }).where({ n: { $gt: 15 } }).execute();
// 关键:before 的 where 必须是 builder 上累积的条件,
// 而不是构造 builder 时的空对象(否则钩子拿不到过滤条件)
expect(seen).toEqual(['before:{"n":{"$gt":15}}', 'after:3']);
});
it('TABLE API 删除的钩子仍触发,且 before 拿到真实 where', async () => {
const seen: string[] = [];
db.on('beforeDelete', (query) => {
seen.push(`before:${JSON.stringify((query as { where: unknown }).where)}`);
});
db.on('afterDelete', (_query, count) => { seen.push(`after:${String(count)}`); });
await db.table('t').delete().where({ id: '1' }).execute();
expect(seen).toEqual(['before:{"id":"1"}', 'after:1']);
});
// -------------------------------------------------------------------
// maxRowsPerQuery 与列校验也覆盖 TABLE API
// -------------------------------------------------------------------
it('TABLE API 的 SELECT 同样受 maxRowsPerQuery 约束', async () => {
const limited = rowsOf(await db.table('t').select(['id']).execute());
// 本引擎默认不限(maxRowsPerQuery 0),此处只验证两条入口结果一致
const viaSql = rowsOf(await db.query('SELECT id FROM t'));
expect(limited).toEqual(viaSql);
});
});
});
describe('[v0.8.0] B-3 单管线:maxRowsPerQuery 对两条入口一致', () => {
it('TABLE API 与 SQL 都被截断到同一行数', async () => {
const db = await MetonaSqlark.create({ name: 'b3-maxrows', mode: 'memory', maxRowsPerQuery: 2 });
await db.defineTable('t', { id: { type: 'string', primaryKey: true } });
await db.query("INSERT INTO t VALUES ('1'),('2')");
await db.query("INSERT INTO t VALUES ('3')");
const viaSql = rowsOf(await db.query('SELECT id FROM t'));
const viaBuilder = rowsOf(await db.table('t').select(['id']).execute());
// 修复前 builder 直通引擎、不受 maxRowsPerQuery 约束 → 返回 3 行
expect(viaBuilder).toHaveLength(2);
expect(viaBuilder).toEqual(viaSql);
await db.close();
});
});