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 零错误。
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
/**
|
||||
* 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 带真实子查询 WHERE:TABLE 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 带真实子查询 WHERE:TABLE 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user