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:
thzxx
2026-09-15 00:15:41 +08:00
parent 841db2e049
commit b20d47bd93
8 changed files with 415 additions and 97 deletions
+21 -13
View File
@@ -9,7 +9,7 @@ import { cloneRow } from '../interface';
import type { IStorageEngine } from '../interface';
import type { QueryPlan, TableSchema, ColumnDef, WhereCondition } from '../../constants';
import { DatabaseError } from '../../constants';
import { matchWhere, applyOrderBy, projectColumns, containsUnresolvedSubqueries } from '../../query/where-matcher';
import { matchWhere, applyOrderBy, projectColumns } from '../../query/where-matcher';
import { stripUndefinedUpdates } from '../../table/schema';
import { compileValidator } from '../../table/validation';
@@ -778,12 +778,16 @@ export class AriaEngine implements IStorageEngine {
this.ensureTable(tableName);
// v0.7.4: 防御 —— QueryBuilder 直通引擎不经 Executor 子查询解析,
// 未解析的 $subquery/$col/$exists 在 matchWhere 中恒 false → 静默 0 行
if (containsUnresolvedSubqueries(query.where)) {
throw new DatabaseError(
'Unresolved subqueries/column references in UPDATE WHERE (use db.query() to execute subqueries)',
'NOT_SUPPORTED',
);
}
// v0.8.0B-3):**不再需要**"检测到未解析标记就抛 NOT_SUPPORTED"的防御。
//
// 那段防御存在的原因是 QueryBuilder 直通引擎、绕过了 Executor 的子查询解析,
// 于是 `$subquery`/`$col`/`$exists` 在引擎层判 UNKNOWN → 静默影响 0 行。
// B-3 把 builder 改为"只产出 AST、执行一律经 Executor"之后,写路径上不可能
// 再出现未解析标记 —— 把"管线缺失"暴露成用户错误(NOT_SUPPORTED)是错误的
// 补救方向:用户没有做错任何事。
//
// 保留 `containsUnresolvedSubqueries` 的导入会给后来者"这里需要防御"的错觉,
// 因此一并移除(见 where-matcher 中该函数仍被 Executor 用于写路径预检)。
const schema = this.schemas.get(tableName)!;
const rows = await this.getAllRows(tableName);
let count = 0;
@@ -1051,12 +1055,16 @@ export class AriaEngine implements IStorageEngine {
this.ensureTable(tableName);
// v0.7.4: 防御 —— QueryBuilder 直通引擎不经 Executor 子查询解析,
// 未解析的 $subquery/$col/$exists 在 matchWhere 中恒 false → 静默 0 行
if (containsUnresolvedSubqueries(query.where)) {
throw new DatabaseError(
'Unresolved subqueries/column references in DELETE WHERE (use db.query() to execute subqueries)',
'NOT_SUPPORTED',
);
}
// v0.8.0B-3):**不再需要**"检测到未解析标记就抛 NOT_SUPPORTED"的防御。
//
// 那段防御存在的原因是 QueryBuilder 直通引擎、绕过了 Executor 的子查询解析,
// 于是 `$subquery`/`$col`/`$exists` 在引擎层判 UNKNOWN → 静默影响 0 行。
// B-3 把 builder 改为"只产出 AST、执行一律经 Executor"之后,写路径上不可能
// 再出现未解析标记 —— 把"管线缺失"暴露成用户错误(NOT_SUPPORTED)是错误的
// 补救方向:用户没有做错任何事。
//
// 保留 `containsUnresolvedSubqueries` 的导入会给后来者"这里需要防御"的错觉,
// 因此一并移除(见 where-matcher 中该函数仍被 Executor 用于写路径预检)。
const rows = await this.getAllRows(tableName);
let count = 0;